修复投票计数与状态同步,完善票据与戳显示

修复投票系统:禁止重复投票、恢复状态、同步大屏

完善投票流程与展示:计数准确、状态可恢复、样式统一
This commit is contained in:
empty
2026-01-28 21:37:05 +08:00
parent 66ca67c137
commit d090c80e50
27 changed files with 1541 additions and 1023 deletions

1
.serena/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/cache

View File

@@ -0,0 +1 @@
补充:移动端 `connection.ts``joinRoom` 成功回调内调用 `requestSync()`,确保刷新后立即同步票据,避免投票状态丢失。注意 lua 脚本改动需重启 server 才生效。

89
.serena/project.yml Normal file
View File

@@ -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: []

View File

@@ -117,9 +117,9 @@ const currentDate = computed(() => {
font-family: 'Kaiti', 'STKaiti', serif; font-family: 'Kaiti', 'STKaiti', serif;
font-weight: bold; font-weight: bold;
&.top { font-size: 10px; letter-spacing: 1px; } &.top { font-size: var(--postmark-top-size, 10px); letter-spacing: 1px; }
&.date { font-size: 8px; letter-spacing: 0.5px; opacity: 0.8; } &.date { font-size: var(--postmark-date-size, 8px); letter-spacing: 0.5px; opacity: 0.8; }
&.bottom { font-size: 9px; } &.bottom { font-size: var(--postmark-bottom-size, 9px); }
} }
.grunge-overlay { .grunge-overlay {

View File

@@ -1,19 +1,18 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import { useVotingStore, TICKET_INFO } from '../stores/voting'; import { useVotingStore } from '../stores/voting';
import { useConnectionStore } from '../stores/connection'; import { useConnectionStore } from '../stores/connection';
import Postmark from './Postmark.vue';
import stampImage from '../assets/images/stamp-horse-2026.png'; import stampImage from '../assets/images/stamp-horse-2026.png';
import { GUOCHAO_ICONS } from '../utils/svgIcons'; import Postmark from './Postmark.vue';
import type { TicketType } from '@gala/shared/constants';
import type { VoteStamp } from '@gala/shared/types';
interface Props { interface Props {
programId: string; programId: string;
programName: string; programName: string;
teamName?: string; teamName?: string;
performer?: string;
remark?: string;
coverImage?: string; coverImage?: string;
index?: number; // For stagger animation index?: number;
status?: 'pending' | 'voting' | 'completed'; status?: 'pending' | 'voting' | 'completed';
isCurrent?: boolean; isCurrent?: boolean;
} }
@@ -30,24 +29,20 @@ const connectionStore = useConnectionStore();
// Animation states // Animation states
const isStamping = ref(false); const isStamping = ref(false);
const stampPhase = ref<'idle' | 'approach' | 'impact' | 'release'>('idle'); const stampPhase = ref<'idle' | 'approach' | 'impact' | 'release'>('idle');
const showInkMark = ref(false);
// Check if this card has a stamp // 检查是否已为此节目投票(任何奖项)
const stampedWith = computed(() => votingStore.getProgramStamp(props.programId)); const votedAward = computed(() => votingStore.getProgramAward(props.programId));
const hasStamp = computed(() => stampedWith.value !== null); const hasVoted = computed(() => votedAward.value !== null);
const stampInfo = computed(() => {
if (!stampedWith.value) return null;
return TICKET_INFO[stampedWith.value];
});
// Can this card receive a stamp (requires voting permission check) // 是否可以投票
const canVote = computed(() => { const canVote = computed(() => {
const check = votingStore.canVoteForProgram(props.programId); const check = votingStore.canVoteForProgram(props.programId);
return check.allowed; return check.allowed;
}); });
const canReceiveStamp = computed(() => { // 是否可以接收投票(需要已选中奖项且未投过)
return votingStore.isStampSelected && !hasStamp.value && canVote.value; const canReceiveVote = computed(() => {
return votingStore.isAwardSelected && canVote.value && !hasVoted.value;
}); });
// Get voting status message // Get voting status message
@@ -59,12 +54,35 @@ const votingStatusLabel = computed(() => {
} }
}); });
// 节目编号(邮编孔样式)
const programNumber = computed(() => {
const num = props.index + 1;
return num.toString().padStart(2, '0');
});
// From 显示:部门·表演者
const fromDisplay = computed(() => {
if (props.performer) {
return `${props.teamName || ''}·${props.performer}`;
}
return props.teamName || 'The Performer';
});
// 当前选中奖项的备注(用于移动端引导)
const selectedAwardRemark = computed(() => {
return votingStore.selectedAward?.remark || props.remark || 'With all our passion';
});
// Stagger delay for entrance animation // Stagger delay for entrance animation
const entranceDelay = computed(() => `${props.index * 100}ms`); const entranceDelay = computed(() => `${props.index * 100}ms`);
async function handleCardClick() { async function handleCardClick() {
if (!votingStore.isStampSelected) return; // 需要先选择奖项
if (hasStamp.value) return; if (!votingStore.isAwardSelected) {
return;
}
if (hasVoted.value) return;
if (!canVote.value) return;
isStamping.value = true; isStamping.value = true;
@@ -75,15 +93,14 @@ async function handleCardClick() {
// Phase 2: Impact (100-150ms) // Phase 2: Impact (100-150ms)
stampPhase.value = 'impact'; stampPhase.value = 'impact';
if (navigator.vibrate) { if (navigator.vibrate) {
navigator.vibrate(40); // Sharp tick navigator.vibrate(40);
} }
await delay(50); await delay(50);
// Phase 3: Release (150-300ms) // Phase 3: Release (150-300ms)
stampPhase.value = 'release'; stampPhase.value = 'release';
showInkMark.value = true;
// Cast vote (optimistic UI) // Cast vote
await votingStore.castVote(props.programId); await votingStore.castVote(props.programId);
await delay(150); await delay(150);
@@ -102,11 +119,11 @@ function delay(ms: number): Promise<void> {
<div <div
class="postcard" class="postcard"
:class="{ :class="{
'has-stamp': hasStamp, 'has-voted': hasVoted,
'can-stamp': canReceiveStamp, 'can-vote': canReceiveVote,
'is-stamping': isStamping, 'is-stamping': isStamping,
'is-current': isCurrent, 'is-current': isCurrent,
'is-disabled': !canVote && !hasStamp, 'is-disabled': !canVote && !hasVoted,
}" }"
:style="{ '--entrance-delay': entranceDelay }" :style="{ '--entrance-delay': entranceDelay }"
@click="handleCardClick" @click="handleCardClick"
@@ -115,65 +132,60 @@ function delay(ms: number): Promise<void> {
<div v-if="status !== 'pending'" class="status-badge" :class="status"> <div v-if="status !== 'pending'" class="status-badge" :class="status">
{{ votingStatusLabel }} {{ votingStatusLabel }}
</div> </div>
<!-- Paper Texture Background --> <!-- Paper Texture Background -->
<div class="paper-texture"> <div class="paper-texture">
<!-- Left: Cover Image (Picture Side) --> <!-- Top Row: Zip codes left, Stamp right -->
<div class="postcard-image"> <div class="top-row">
<img v-if="coverImage" :src="coverImage" :alt="programName" /> <div class="zip-codes">
<div v-else class="image-placeholder"> <div v-for="(code, idx) in ['2', '0', '2', '6', '0', (index + 1).toString()]" :key="idx" class="zip-box">
<span class="placeholder-text">{{ programName }}</span> {{ code }}
</div>
</div>
<div class="stamp-box">
<img :src="stampImage" alt="邮票" class="stamp-image" />
</div> </div>
</div> </div>
<!-- Right: Content Area (Address Side) --> <!-- Main Content Area -->
<div class="postcard-content"> <div class="main-content">
<!-- Top: Program Info --> <!-- Program Info (Title & Remark) -->
<div class="content-header"> <div class="program-info">
<h3 class="program-title">{{ programName }}</h3> <h2 class="program-name">{{ programName }}</h2>
<p v-if="teamName" class="team-name">{{ teamName }}</p> <div class="remark-box">
</div> <p class="remark-text">{{ selectedAwardRemark }}</p>
<!-- Middle: Writing Area (Short Quote) -->
<div class="writing-area">
<p class="micro-copy handwritten">With all our passion</p>
<span class="caption">倾情呈现</span>
</div>
<!-- Bottom: Address Block -->
<div class="address-block">
<div class="address-row">
<span class="label">From:</span>
<span class="handwritten">{{ teamName || 'The Performer' }}</span>
<div class="address-line"></div>
</div>
<div class="address-row">
<span class="label">To:</span>
<span class="handwritten">The 2026 Company Family</span>
<div class="address-line"></div>
</div> </div>
</div> </div>
<!-- Stamp Area --> <!-- Address Area (Bottom Right) -->
<div class="stamp-zone"> <div class="address-area">
<!-- Real stamp image (always visible) --> <div class="address-line">
<img :src="stampImage" alt="邮票" class="stamp-image" /> <span class="label"></span>
<span class="handwritten">{{ fromDisplay }}</span>
<!-- Postmark overlay on bottom-left of stamp --> </div>
<div class="address-line">
<span class="label"></span>
<span class="handwritten">2026 全体家人</span>
</div>
</div>
</div>
<!-- Voted Overlay -->
<div v-if="hasVoted && votedAward" class="voted-overlay">
<div class="voted-postmark-round">
<Postmark <Postmark
v-if="hasStamp && stampInfo" :award-name="votedAward.name"
:award-name="stampInfo.name" :award-icon-key="votedAward.id"
:award-icon-key="stampedWith!" :user-name="connectionStore.userName || '访客'"
:user-name="connectionStore.userName || ''" color="red"
color="gold"
class="applied-stamp"
/> />
</div> </div>
</div> </div>
</div> </div>
<!-- Stamping Tool Overlay --> <!-- Stamping Animation Overlay -->
<Transition name="stamp-tool"> <Transition name="stamp-tool">
<div v-if="isStamping && votingStore.selectedStamp" class="stamp-tool-overlay"> <div v-if="isStamping && votingStore.selectedAward" class="stamp-tool-overlay">
<div <div
class="stamp-tool" class="stamp-tool"
:class="[`phase-${stampPhase}`]" :class="[`phase-${stampPhase}`]"
@@ -184,10 +196,9 @@ function delay(ms: number): Promise<void> {
</div> </div>
<div class="tool-base"> <div class="tool-base">
<div class="base-plate"></div> <div class="base-plate"></div>
<div class="base-relief" v-html="GUOCHAO_ICONS[votingStore.selectedStamp as keyof typeof GUOCHAO_ICONS]"></div> <div class="base-relief">{{ votingStore.selectedAward.icon }}</div>
</div> </div>
<!-- Impact Effects: Gold Dust / Particles -->
<div v-if="stampPhase === 'impact' || stampPhase === 'release'" class="impact-effects"> <div v-if="stampPhase === 'impact' || stampPhase === 'release'" class="impact-effects">
<div v-for="i in 12" :key="i" class="gold-dust"></div> <div v-for="i in 12" :key="i" class="gold-dust"></div>
</div> </div>
@@ -202,235 +213,189 @@ function delay(ms: number): Promise<void> {
@use 'sass:color'; @use 'sass:color';
@use '../assets/styles/variables.scss' as *; @use '../assets/styles/variables.scss' as *;
// Paper & Ink colors $paper-cream: #FDFBF7;
$paper-cream: #f8f4e8;
$paper-lines: rgba(180, 160, 140, 0.3);
$ink-blue: #000080; $ink-blue: #000080;
$ink-red: #c21f30; $ink-red: #c41e3a;
$ink-charcoal: #333;
.postcard { .postcard {
position: relative; position: relative;
width: 100%;
background-color: $paper-cream;
border-radius: 4px; border-radius: 4px;
overflow: hidden; overflow: hidden;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1); box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
transition: transform 0.2s ease, box-shadow 0.2s ease, opacity 0.3s ease; transition: transform 0.2s ease, box-shadow 0.2s ease;
background-color: $paper-cream; margin-bottom: 20px;
border: 1px solid #e0d8c0;
// Entrance animation &.can-vote {
animation: postcard-enter 0.5s ease-out backwards; box-shadow: 0 4px 20px rgba($color-gold, 0.3), 0 0 0 2px rgba($color-gold, 0.4);
animation-delay: var(--entrance-delay, 0ms); animation: pulse-glow 2s infinite;
&:active:not(.is-stamping) {
transform: scale(0.98);
}
&.can-stamp {
box-shadow: 0 4px 20px rgba($color-gold, 0.3), 0 0 0 2px rgba($color-gold, 0.5);
animation: pulse-glow 1.5s ease-in-out infinite;
}
&.has-stamp {
.paper-texture {
background-color: color.adjust($paper-cream, $lightness: -2%);
}
} }
&.is-current { &.is-current {
box-shadow: 0 4px 25px rgba($color-gold, 0.4), 0 0 0 3px $color-gold; box-shadow: 0 4px 25px rgba($color-gold, 0.4), 0 0 0 3px $color-gold;
} }
&.is-disabled {
opacity: 0.6;
pointer-events: none;
}
}
// Status Badge
.status-badge {
position: absolute;
top: 8px;
left: 8px;
padding: 4px 10px;
border-radius: 4px;
font-size: 10px;
font-weight: 600;
z-index: 5;
&.voting {
background: $color-gold;
color: #000;
animation: pulse-badge 1.5s ease-in-out infinite;
}
&.completed {
background: rgba(0, 0, 0, 0.5);
color: #fff;
}
}
@keyframes pulse-badge {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
} }
.paper-texture { .paper-texture {
display: flex; padding: 16px;
background-color: transparent;
// Paper grain noise texture
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.06'/%3E%3C/svg%3E");
min-height: 160px;
}
// Left: Image area
.postcard-image {
flex: 0 0 40%;
position: relative; position: relative;
overflow: hidden; min-height: 180px;
border-right: 1px dashed $paper-lines; background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.05'/%3E%3C/svg%3E");
img {
width: 100%;
height: 100%;
object-fit: cover;
}
} }
.image-placeholder { /* Top Row: Zip + Stamp */
width: 100%; .top-row {
height: 100%; display: flex;
min-height: 160px; justify-content: space-between;
background: linear-gradient(135deg, $color-primary 0%, color.adjust($color-primary, $lightness: -10%) 100%); align-items: flex-start;
margin-bottom: 15px;
}
.zip-codes {
display: flex;
gap: 4px;
}
.zip-box {
width: 20px;
height: 24px;
border: 1.5px solid $ink-red;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: $spacing-md;
}
.placeholder-text {
font-size: $font-size-xl;
font-weight: bold;
color: $color-gold;
text-align: center;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
}
// Right: Content area
.postcard-content {
flex: 1;
padding: $spacing-md;
display: flex;
flex-direction: column;
position: relative;
}
.content-header {
margin-bottom: $spacing-xs;
}
.program-title {
font-size: $font-size-lg;
font-weight: bold;
color: #2a2a2a;
font-family: 'Noto Serif SC', serif;
margin-bottom: 0;
}
.team-name {
font-size: 10px;
color: #888;
font-family: 'Courier New', monospace; font-family: 'Courier New', monospace;
margin-bottom: $spacing-xs; font-size: 13px;
font-weight: bold;
color: #333;
background: #fff;
} }
// Writing style .stamp-box {
.handwritten { width: 50px;
font-family: 'Ma Shan Zheng', 'Kaiti', 'Brush Script MT', cursive; height: 50px;
color: $ink-blue; border: 1px solid #ddd;
display: inline-block; padding: 2px;
transform: rotate(-1deg); background: #fff;
font-size: $font-size-md;
}
.writing-area {
margin-bottom: $spacing-sm;
.micro-copy {
font-size: $font-size-sm;
margin-bottom: 2px;
}
.caption {
font-size: 10px;
color: #999;
font-style: italic;
}
}
.address-block {
margin-top: auto;
display: flex;
flex-direction: column;
gap: 12px;
}
.address-row {
position: relative;
display: flex;
align-items: flex-end;
gap: 4px;
.label {
font-size: 10px;
color: #444;
font-weight: bold;
min-width: 30px;
}
.handwritten {
flex: 1;
padding-left: 4px;
position: relative;
z-index: 1;
}
}
.address-line {
position: absolute;
bottom: 2px;
left: 30px;
right: 0;
border-bottom: 1px dotted #ccc;
}
// Stamp zone
.stamp-zone {
position: absolute;
top: $spacing-sm;
right: $spacing-sm;
width: 70px;
height: 70px;
display: flex;
align-items: flex-start;
justify-content: flex-end;
} }
.stamp-image { .stamp-image {
width: 60px; width: 100%;
height: 60px; height: 100%;
object-fit: contain; object-fit: contain;
border: 1px solid rgba(0, 0, 0, 0.1);
box-shadow: 1px 1px 3px rgba(0,0,0,0.1);
} }
.applied-stamp { /* Main Content: Left Info, Right Address */
.main-content {
display: flex;
justify-content: space-between;
align-items: flex-end;
}
.program-info {
flex: 1;
padding-right: 20px;
}
.program-name {
font-family: 'SimSun', 'Songti SC', 'STSong', serif;
font-size: 24px;
font-weight: bold;
color: $ink-red;
margin-bottom: 10px;
letter-spacing: 2px;
}
.remark-box {
border: 1px solid #dcdcdc;
padding: 8px 12px;
background: rgba(255, 255, 255, 0.5);
display: inline-block;
max-width: 180px;
}
.remark-text {
font-size: 11px;
color: #666;
font-style: italic;
margin: 0;
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.address-area {
min-width: 130px;
padding-bottom: 5px;
}
.address-line {
display: flex;
align-items: baseline;
gap: 4px;
border-bottom: 1px dotted #ccc;
margin-bottom: 8px;
padding-bottom: 2px;
.label {
font-size: 11px;
color: #888;
min-width: 20px;
}
.handwritten {
font-family: 'Ma Shan Zheng', 'Kaiti', 'STKaiti', cursive;
font-size: 13px;
color: $ink-blue;
flex: 1;
}
}
/* Overlay for voted status */
.voted-overlay {
position: absolute; position: absolute;
bottom: -5px; inset: 0;
left: -10px; background: rgba(255, 255, 255, 0.1);
mix-blend-mode: multiply; pointer-events: none;
transform: rotate(-8deg); z-index: 10;
z-index: 2;
} }
// Stamp Tool Overlay .voted-postmark-round {
position: absolute;
top: 8px;
right: 52px;
transform: rotate(-12deg);
}
.voted-postmark-round :deep(.postmark) {
width: 104px;
height: 104px;
--postmark-top-size: 12px;
--postmark-date-size: 10px;
--postmark-bottom-size: 11px;
}
/* Animation badge */
.status-badge {
position: absolute;
top: 0;
left: 50%;
transform: translateX(-50%);
padding: 2px 10px;
font-size: 10px;
font-weight: bold;
border-radius: 0 0 6px 6px;
z-index: 20;
&.voting { background: $color-gold; color: #000; }
&.completed { background: #666; color: #fff; }
}
/* Stamp Tool - same as before but ensured it fits */
.stamp-tool-overlay { .stamp-tool-overlay {
position: absolute; position: absolute;
inset: 0; inset: 0;
@@ -453,22 +418,8 @@ $ink-charcoal: #333;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
filter: drop-shadow(0 4px 8px rgba(0,0,0,0.4)); .handle-top { width: 16px; height: 8px; background: #4a342e; border-radius: 50% / 100% 100% 0 0; }
.handle-body { width: 20px; height: 35px; background: linear-gradient(90deg, #5d4037 0%, #8d6e63 50%, #5d4037 100%); border-radius: 2px 2px 4px 4px; }
.handle-top {
width: 20px;
height: 10px;
background: #4a342e;
border-radius: 50% / 100% 100% 0 0;
}
.handle-body {
width: 24px;
height: 45px;
background: linear-gradient(90deg, #5d4037 0%, #8d6e63 50%, #5d4037 100%);
border-radius: 2px 2px 4px 4px;
border: 1px solid rgba(255,255,255,0.1);
}
} }
.tool-base { .tool-base {
@@ -476,118 +427,44 @@ $ink-charcoal: #333;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
margin-top: -2px; margin-top: -2px;
.base-plate { width: 44px; height: 10px; background: linear-gradient(90deg, #aa8a31 0%, #f0c239 50%, #aa8a31 100%); border-radius: 4px; }
.base-plate { .base-relief { width: 40px; height: 40px; background: $ink-red; border-radius: 2px; display: flex; align-items: center; justify-content: center; font-size: 24px; color: #fff; }
width: 54px;
height: 12px;
background: linear-gradient(90deg, #aa8a31 0%, #f0c239 50%, #aa8a31 100%);
border-radius: 4px;
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
}
.base-relief {
width: 48px;
height: 48px;
margin-top: -2px;
background: $ink-red;
border-radius: 2px;
display: flex;
align-items: center;
justify-content: center;
color: rgba(255,255,255,0.9);
padding: 8px;
box-shadow: inset 0 0 10px rgba(0,0,0,0.5);
:deep(svg) {
width: 100%;
height: 100%;
}
}
} }
// Animation phases .phase-approach { transform: scale(1.4) translateY(-80px) rotate(-10deg); opacity: 0.8; }
.phase-approach { .phase-impact { transform: scale(0.9) translateY(0) rotate(0); opacity: 1; }
transform: scale(1.4) translateY(-80px) rotate(-10deg); .phase-release { transform: scale(1.1) translateY(-100px) rotate(5deg); opacity: 0; }
opacity: 0.8;
}
.phase-impact { @keyframes pulse-glow {
transform: scale(0.9) translateY(0) rotate(0); 0%, 100% { box-shadow: 0 4px 15px rgba(0,0,0,0.1), 0 0 0 1px rgba($color-gold, 0.2); }
opacity: 1; 50% { box-shadow: 0 4px 25px rgba($color-gold, 0.3), 0 0 0 3px rgba($color-gold, 0.4); }
}
.phase-release {
transform: scale(1.1) translateY(-100px) rotate(5deg);
opacity: 0;
}
// Impact Effects: Gold Dust
.impact-effects {
position: absolute;
top: 90%;
left: 50%;
width: 100px;
height: 100px;
pointer-events: none;
display: flex;
align-items: center;
justify-content: center;
} }
.gold-dust { .gold-dust {
position: absolute; position: absolute;
width: 4px; width: 3px;
height: 4px; height: 3px;
background: $color-gold; background: $color-gold;
border-radius: 50%; border-radius: 50%;
filter: blur(1px); filter: blur(0.5px);
animation: dust-fly 0.6s ease-out forwards; animation: dust-fly 0.6s ease-out forwards;
@for $i from 1 through 12 { @for $i from 1 through 12 {
&:nth-child(#{$i}) { &:nth-child(#{$i}) {
$angle: $i * 30deg; $angle: $i * 30deg;
$dist: 40px + random(40); $dist: 25px + random(25);
--tx: #{math.cos($angle) * $dist}; --tx: #{math.cos($angle) * $dist};
--ty: #{math.sin($angle) * $dist}; --ty: #{math.sin($angle) * $dist};
animation-delay: random(50) * 1ms; animation-delay: random(40) * 1ms;
} }
} }
} }
@keyframes dust-fly { @keyframes dust-fly {
0% { transform: translate(0, 0) scale(1); opacity: 1; } 0% { transform: translate(0, 0) scale(1.5); opacity: 1; }
100% { transform: translate(var(--tx), var(--ty)) scale(0); opacity: 0; } 100% { transform: translate(var(--tx), var(--ty)) scale(0); opacity: 0; }
} }
// Keyframes .stamp-tool-enter-active, .stamp-tool-leave-active { transition: opacity 0.2s; }
@keyframes postcard-enter { .stamp-tool-enter-from, .stamp-tool-leave-to { opacity: 0; }
from {
opacity: 0;
transform: translateY(20px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes pulse-glow {
0%, 100% {
box-shadow: 0 4px 20px rgba($color-gold, 0.3), 0 0 0 2px rgba($color-gold, 0.3);
}
50% {
box-shadow: 0 4px 30px rgba($color-gold, 0.5), 0 0 0 3px rgba($color-gold, 0.5);
}
}
// Transition for stamp tool
.stamp-tool-enter-active,
.stamp-tool-leave-active {
transition: opacity 0.2s;
}
.stamp-tool-enter-from,
.stamp-tool-leave-to {
opacity: 0;
}
</style> </style>

View File

@@ -1,66 +1,85 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed } from 'vue';
import { useVotingStore, TICKET_INFO } from '../stores/voting'; import { useVotingStore } from '../stores/voting';
import { TICKET_TYPES, type TicketType } from '@gala/shared/constants';
import { GUOCHAO_ICONS } from '../utils/svgIcons';
const emit = defineEmits<{
select: [ticketType: TicketType];
}>();
const votingStore = useVotingStore(); const votingStore = useVotingStore();
const medals = computed(() => { // 奖项帧列表
return TICKET_TYPES.map((type, index) => ({ const awardFrames = computed(() => {
type, return votingStore.awards.map((award, index) => ({
...TICKET_INFO[type], id: award.id,
isUsed: votingStore.tickets[type] !== null, name: award.name,
isSelected: votingStore.selectedStamp === type, icon: award.icon,
// Fan layout angle order: index + 1,
angle: (index - 3) * 8, isUsed: votingStore.isAwardUsed(award.id),
svg: GUOCHAO_ICONS[type as keyof typeof GUOCHAO_ICONS], isSelected: votingStore.selectedAwardId === award.id,
votedProgram: votingStore.tickets[award.id] || null,
})); }));
}); });
function handleMedalClick(type: TicketType) { // 点击帧
if (votingStore.tickets[type] !== null) return; function handleFrameClick(awardId: string) {
if (votingStore.isAwardUsed(awardId)) {
if (votingStore.selectedStamp === type) { // 已使用的奖项,显示已投状态
votingStore.deselectStamp(); return;
} else {
votingStore.selectStamp(type);
emit('select', type);
} }
if (votingStore.selectedAwardId === awardId) {
votingStore.deselectAward();
} else {
votingStore.selectAward(awardId);
}
}
// 获取节目简称
function getProgramShortName(programId: string | null): string {
if (!programId) return '';
const program = votingStore.programs.find(p => p.id === programId);
return program ? program.name.slice(0, 2) : '';
} }
</script> </script>
<template> <template>
<div class="voting-dock"> <div class="voting-dock">
<div class="dock-tray"> <!-- 选中提示 -->
<div <div v-if="votingStore.selectedAward" class="selection-hint">
v-for="medal in medals" <span class="hint-icon">{{ votingStore.selectedAward.icon }}</span>
:key="medal.type" <span class="hint-text">请点击节目投出{{ votingStore.selectedAward.name }}</span>
class="medal-slot"
:class="{
'is-used': medal.isUsed,
'is-selected': medal.isSelected,
}"
:style="{ '--angle': `${medal.angle}deg` }"
@click="handleMedalClick(medal.type)"
>
<div class="medal-icon">
<div class="medal-face">
<div class="medal-svg-wrapper" v-html="medal.svg"></div>
</div>
<span class="medal-label">{{ medal.name.slice(2) }}</span>
</div>
</div>
</div> </div>
<div v-if="votingStore.isStampSelected" class="dock-hint"> <!-- 电影胶片条 -->
<span class="hint-arrow">👆</span> <div class="film-strip">
<span>选择节目盖章投票</span> <!-- 上方齿孔 -->
<div class="sprocket-row top">
<div v-for="n in Math.max(awardFrames.length + 2, 8)" :key="'top-' + n" class="sprocket-hole"></div>
</div>
<!-- 帧容器可横向滑动 -->
<div class="frames-container">
<div class="frames-scroll">
<div
v-for="frame in awardFrames"
:key="frame.id"
class="film-frame"
:class="{
'is-used': frame.isUsed,
'is-selected': frame.isSelected,
}"
@click="handleFrameClick(frame.id)"
>
<div class="frame-icon">{{ frame.icon }}</div>
<div class="frame-name">{{ frame.name.slice(0, 4) }}</div>
<div v-if="frame.isUsed" class="voted-program">
已投出
</div>
</div>
</div>
</div>
<!-- 下方齿孔 -->
<div class="sprocket-row bottom">
<div v-for="n in Math.max(awardFrames.length + 2, 8)" :key="'bottom-' + n" class="sprocket-hole"></div>
</div>
</div> </div>
</div> </div>
</template> </template>
@@ -77,143 +96,159 @@ function handleMedalClick(type: TicketType) {
padding-bottom: env(safe-area-inset-bottom); padding-bottom: env(safe-area-inset-bottom);
} }
.dock-tray { // 选中提示
.selection-hint {
display: flex; display: flex;
align-items: center;
justify-content: center; justify-content: center;
align-items: flex-end; gap: 8px;
padding: $spacing-md $spacing-md $spacing-lg; background: linear-gradient(135deg, $color-gold 0%, #b8860b 100%);
// Red Glassmorphism color: #1a1a1a;
background: $color-surface-glass; padding: 8px 16px;
backdrop-filter: $backdrop-blur; margin: 0 16px 8px;
-webkit-backdrop-filter: $backdrop-blur; border-radius: 20px;
border-top: 1px solid rgba($color-gold, 0.3); font-weight: 600;
box-shadow: 0 -10px 30px rgba(0, 0, 0, 0.3); animation: pulse-hint 1.5s ease-in-out infinite;
border-radius: $radius-xl $radius-xl 0 0;
} }
.medal-slot { .hint-icon {
position: relative; font-size: 18px;
transform: rotate(var(--angle)) translateY(0); }
transform-origin: bottom center;
transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); .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; cursor: pointer;
margin: 0 -2px; transition: all 0.2s ease;
position: relative;
flex-shrink: 0;
&:active { &:active {
transform: rotate(var(--angle)) translateY(-4px) scale(0.95); transform: scale(0.95);
} }
// 已使用
&.is-used { &.is-used {
opacity: 0.2; background: linear-gradient(135deg, #1a3a1a 0%, #0a2a0a 100%);
filter: grayscale(1) brightness(0.5); border-color: #2a5a2a;
pointer-events: none; opacity: 0.8;
.frame-icon {
opacity: 0.6;
}
.frame-name {
color: #4a8a4a;
}
} }
// 选中状态
&.is-selected { &.is-selected {
transform: rotate(0deg) translateY(-25px) scale(1.25); background: linear-gradient(135deg, $color-primary 0%, $color-primary-dark 100%);
z-index: 10; border-color: $color-gold;
box-shadow:
.medal-face { 0 0 12px rgba($color-gold, 0.5),
background: linear-gradient(135deg, $color-gold 0%, #fff 50%, $color-gold 100%); inset 0 1px 2px rgba(255, 255, 255, 0.2);
color: $color-primary; transform: translateY(-4px);
box-shadow:
0 8px 24px rgba($color-gold, 0.6), .frame-name {
0 0 0 2px rgba($color-gold, 0.4); color: $color-gold;
animation: selected-glow 1.5s ease-in-out infinite;
} }
} }
} }
.medal-icon { .frame-icon {
display: flex; font-size: 22px;
flex-direction: column; line-height: 1;
align-items: center;
gap: $spacing-sm;
} }
.medal-face { .frame-name {
width: 52px; font-size: 10px;
height: 52px; color: #888;
background: linear-gradient(135deg, $color-primary-dark 0%, $color-primary 50%, $color-primary-dark 100%); margin-top: 4px;
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);
white-space: nowrap; white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 58px;
text-align: center;
} }
.dock-hint { .voted-program {
position: absolute; font-size: 9px;
top: -40px; color: #4a8a4a;
left: 50%; margin-top: 2px;
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);
}
} }
</style> </style>

View File

@@ -107,6 +107,9 @@ export const useConnectionStore = defineStore('connection', () => {
// Start heartbeat // Start heartbeat
startHeartbeat(); startHeartbeat();
// Request initial admin state to ensure UI is in sync
socketInstance.emit(SOCKET_EVENTS.ADMIN_STATE_SYNC as any);
}); });
socketInstance.on('disconnect', (reason) => { socketInstance.on('disconnect', (reason) => {
@@ -144,6 +147,12 @@ export const useConnectionStore = defineStore('connection', () => {
if (data.userVotedCategories) { if (data.userVotedCategories) {
votedCategories.value = 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 // Admin state sync - update voting state
socketInstance.on(SOCKET_EVENTS.ADMIN_STATE_SYNC as any, (state: AdminState) => { socketInstance.on(SOCKET_EVENTS.ADMIN_STATE_SYNC as any, (state: AdminState) => {
@@ -157,6 +166,7 @@ export const useConnectionStore = defineStore('connection', () => {
programs: state.voting.programs, programs: state.voting.programs,
currentProgramId: state.voting.currentProgramId, currentProgramId: state.voting.currentProgramId,
allowLateCatch: state.voting.allowLateCatch, allowLateCatch: state.voting.allowLateCatch,
awards: state.voting.awards,
}); });
}); });
}); });
@@ -192,6 +202,14 @@ export const useConnectionStore = defineStore('connection', () => {
if (response.data.votedCategories) { if (response.data.votedCategories) {
votedCategories.value = 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();
} }
} }
); );

View File

@@ -1,49 +1,46 @@
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { ref, computed } from 'vue'; import { ref, computed } from 'vue';
import { useConnectionStore } from './connection'; import { useConnectionStore } from './connection';
import { TICKET_TYPES, type TicketType } from '@gala/shared/constants';
import { showToast } from 'vant'; import { showToast } from 'vant';
import type { VoteStamp } from '@gala/shared/types';
// Ticket display info // 奖项配置接口(从服务器同步)
export const TICKET_INFO: Record<TicketType, { name: string; icon: string }> = { export interface AwardConfig {
creative: { name: '最佳创意', icon: 'creative' }, id: string;
visual: { name: '最佳视觉', icon: 'visual' }, name: string;
atmosphere: { name: '最佳氛围', icon: 'atmosphere' }, icon: string;
performance: { name: '最佳表演', icon: 'performance' }, order: number;
teamwork: { name: '最佳团队', icon: 'teamwork' },
popularity: { name: '最受欢迎', icon: 'popularity' },
potential: { name: '最具潜力', icon: 'potential' },
};
interface PendingVote {
ticketType: TicketType;
programId: string;
timestamp: number;
} }
interface VotingProgram { // 节目接口
export interface VotingProgram {
id: string; id: string;
name: string; name: string;
teamName: string; teamName: string;
performer?: string;
order: number; order: number;
remark?: string;
status: 'pending' | 'voting' | 'completed'; status: 'pending' | 'voting' | 'completed';
votes: number; votes: number;
} }
interface PendingVote {
awardId: string;
programId: string;
timestamp: number;
}
export const useVotingStore = defineStore('voting', () => { export const useVotingStore = defineStore('voting', () => {
const connectionStore = useConnectionStore(); const connectionStore = useConnectionStore();
// State: ticketType -> programId (null if unused) // ============================================================================
const tickets = ref<Record<TicketType, string | null>>({ // 奖项票系统:每个奖项一张票,票数 = 奖项数 = 节目数
creative: null, // ============================================================================
visual: null,
atmosphere: null, // 奖项列表(从服务器同步)
performance: null, const awards = ref<AwardConfig[]>([]);
teamwork: null,
popularity: null, // 已投票记录awardId -> programId
potential: null, const tickets = ref<Record<string, string | null>>({});
});
// Server-synced voting state // Server-synced voting state
const votingOpen = ref(false); const votingOpen = ref(false);
@@ -52,8 +49,8 @@ export const useVotingStore = defineStore('voting', () => {
const currentProgramId = ref<string | null>(null); const currentProgramId = ref<string | null>(null);
const allowLateCatch = ref(true); const allowLateCatch = ref(true);
// Currently selected stamp in dock // 当前选中的奖项(准备投票)
const selectedStamp = ref<TicketType | null>(null); const selectedAwardId = ref<string | null>(null);
// Pending votes (optimistic updates waiting for server confirmation) // Pending votes (optimistic updates waiting for server confirmation)
const pendingVotes = ref<Map<string, PendingVote>>(new Map()); const pendingVotes = ref<Map<string, PendingVote>>(new Map());
@@ -61,39 +58,83 @@ export const useVotingStore = defineStore('voting', () => {
// Animation state for cards // Animation state for cards
const stampingCard = ref<string | null>(null); const stampingCard = ref<string | null>(null);
// ============================================================================
// Computed // 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 { const availableAwards = computed(() => {
for (const ticketType of TICKET_TYPES) { return awards.value.filter(a => tickets.value[a.id] === null || tickets.value[a.id] === undefined);
if (tickets.value[ticketType] === programId) { });
return ticketType;
// ============================================================================
// 奖项票操作
// ============================================================================
// 初始化票据状态(当奖项列表更新时)
function initTickets() {
const newTickets: Record<string, string | null> = {};
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; return null;
} }
// Select a stamp from the dock // 选中一个奖项
function selectStamp(ticketType: TicketType) { function selectAward(awardId: string) {
if (tickets.value[ticketType] !== null) { if (isAwardUsed(awardId)) {
showToast({ message: '该印章已使用', position: 'bottom' }); const award = awards.value.find(a => a.id === awardId);
showToast({ message: `${award?.name || '该奖项'}已投出`, position: 'bottom' });
return; return;
} }
selectedStamp.value = ticketType; selectedAwardId.value = awardId;
} }
// Deselect stamp // 取消选中
function deselectStamp() { function deselectAward() {
selectedStamp.value = null; selectedAwardId.value = null;
} }
// Trigger haptic feedback // Trigger haptic feedback
@@ -103,39 +144,39 @@ export const useVotingStore = defineStore('voting', () => {
} }
} }
// Cast vote with optimistic update // 投票:将选中的奖项投给某个节目
async function castVote(programId: string): Promise<boolean> { async function castVote(programId: string): Promise<boolean> {
const ticketType = selectedStamp.value; const awardId = selectedAwardId.value;
if (!ticketType) { if (!awardId) {
showToast({ message: '请先选择一个印章', position: 'bottom' }); showToast({ message: '请先选择一个奖项', position: 'bottom' });
return false; return false;
} }
// Check if already voted for this program // 检查是否已为该节目投过票(任何奖项)
const existingStamp = getProgramStamp(programId); const existingAward = getProgramAward(programId);
if (existingStamp) { if (existingAward) {
showToast({ message: '已为该节目投过票', position: 'bottom' }); showToast({ message: `已将${existingAward.name}投给该节目`, position: 'bottom' });
return false; return false;
} }
// Optimistic update // Optimistic update
const previousValue = tickets.value[ticketType]; const previousValue = tickets.value[awardId];
tickets.value[ticketType] = programId; tickets.value[awardId] = programId;
stampingCard.value = programId; stampingCard.value = programId;
// Trigger haptic // Trigger haptic
triggerHaptic(); triggerHaptic();
// Create pending vote record // Create pending vote record
const voteKey = `${ticketType}:${programId}`; const voteKey = `${awardId}:${programId}`;
pendingVotes.value.set(voteKey, { pendingVotes.value.set(voteKey, {
ticketType, awardId,
programId, programId,
timestamp: Date.now(), timestamp: Date.now(),
}); });
// Clear selection // Clear selection
selectedStamp.value = null; selectedAwardId.value = null;
// Call backend // Call backend
try { try {
@@ -153,7 +194,7 @@ export const useVotingStore = defineStore('voting', () => {
socket.emit('vote:submit' as any, { socket.emit('vote:submit' as any, {
candidateId: programId, candidateId: programId,
category: ticketType, category: awardId, // 奖项ID作为category
clientTimestamp: Date.now(), clientTimestamp: Date.now(),
localId, localId,
}, (response: any) => { }, (response: any) => {
@@ -163,7 +204,6 @@ export const useVotingStore = defineStore('voting', () => {
}); });
if (!result.success) { if (!result.success) {
// Use server message if available
const errorMessage = result.message || '投票失败,请重试'; const errorMessage = result.message || '投票失败,请重试';
throw new Error(errorMessage); throw new Error(errorMessage);
} }
@@ -174,7 +214,7 @@ export const useVotingStore = defineStore('voting', () => {
} catch (error) { } catch (error) {
// Revert optimistic update // Revert optimistic update
tickets.value[ticketType] = previousValue; tickets.value[awardId] = previousValue;
pendingVotes.value.delete(voteKey); pendingVotes.value.delete(voteKey);
const errorMessage = error instanceof Error ? error.message : '投票失败,请重试'; 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<boolean> { async function revokeVote(awardId: string): Promise<boolean> {
const programId = tickets.value[ticketType]; const programId = tickets.value[awardId];
if (!programId) return false; if (!programId) return false;
// Optimistic update // Optimistic update
tickets.value[ticketType] = null; tickets.value[awardId] = null;
try { try {
const socket = connectionStore.getSocket(); const socket = connectionStore.getSocket();
if (!socket) throw new Error('Not connected'); if (!socket) throw new Error('Not connected');
const result = await new Promise<{ success: boolean }>((resolve) => { 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); resolve(response);
}); });
setTimeout(() => resolve({ success: false }), 10000); setTimeout(() => resolve({ success: false }), 10000);
@@ -217,17 +257,23 @@ export const useVotingStore = defineStore('voting', () => {
} catch { } catch {
// Revert // Revert
tickets.value[ticketType] = programId; tickets.value[awardId] = programId;
showToast({ message: '撤销失败', position: 'bottom', type: 'fail' }); showToast({ message: '撤销失败', position: 'bottom', type: 'fail' });
return false; return false;
} }
} }
// Sync state from server // Sync state from server
function syncFromServer(serverTickets: Record<TicketType, string | null>) { function syncFromServer(serverTickets: Record<string, string | null>) {
tickets.value = { ...serverTickets }; tickets.value = { ...serverTickets };
} }
// Sync awards from server
function syncAwards(serverAwards: AwardConfig[]) {
awards.value = serverAwards;
initTickets();
}
// Sync voting state from AdminState // Sync voting state from AdminState
function syncVotingState(state: { function syncVotingState(state: {
votingOpen?: boolean; votingOpen?: boolean;
@@ -235,26 +281,29 @@ export const useVotingStore = defineStore('voting', () => {
programs?: VotingProgram[]; programs?: VotingProgram[];
currentProgramId?: string | null; currentProgramId?: string | null;
allowLateCatch?: boolean; allowLateCatch?: boolean;
awards?: AwardConfig[];
}) { }) {
if (state.votingOpen !== undefined) votingOpen.value = state.votingOpen; if (state.votingOpen !== undefined) votingOpen.value = state.votingOpen;
if (state.votingPaused !== undefined) votingPaused.value = state.votingPaused; if (state.votingPaused !== undefined) votingPaused.value = state.votingPaused;
if (state.programs !== undefined) programs.value = state.programs; if (state.programs !== undefined) programs.value = state.programs;
if (state.currentProgramId !== undefined) currentProgramId.value = state.currentProgramId; if (state.currentProgramId !== undefined) currentProgramId.value = state.currentProgramId;
if (state.allowLateCatch !== undefined) allowLateCatch.value = state.allowLateCatch; if (state.allowLateCatch !== undefined) allowLateCatch.value = state.allowLateCatch;
if (state.awards !== undefined) syncAwards(state.awards);
} }
// Check if voting is allowed for a specific program // 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 } { function canVoteForProgram(programId: string): { allowed: boolean; reason?: string } {
// Check if voting is open
if (!votingOpen.value) { if (!votingOpen.value) {
return { allowed: false, reason: '投票尚未开始' }; return { allowed: false, reason: '投票尚未开始' };
} }
if (votingPaused.value) { if (votingPaused.value) {
return { allowed: false, reason: '投票已暂停' }; 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 }; return { allowed: true };
} }
@@ -264,10 +313,6 @@ export const useVotingStore = defineStore('voting', () => {
if (program) { if (program) {
program.votes = data.newCount; 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 // Get current voting program
@@ -286,20 +331,34 @@ export const useVotingStore = defineStore('voting', () => {
}); });
return { return {
// 奖项票状态
awards,
tickets, tickets,
selectedStamp, selectedAwardId,
selectedAward,
pendingVotes, pendingVotes,
stampingCard, stampingCard,
usedTickets,
availableTickets, // Computed
isStampSelected, usedTicketCount,
getProgramStamp, totalTicketCount,
selectStamp, remainingTicketCount,
deselectStamp, isAwardSelected,
usedAwards,
availableAwards,
// 操作
initTickets,
isAwardUsed,
getProgramAward,
selectAward,
deselectAward,
castVote, castVote,
revokeVote, revokeVote,
syncFromServer, syncFromServer,
// New exports syncAwards,
// 投票状态
votingOpen, votingOpen,
votingPaused, votingPaused,
programs, programs,
@@ -312,4 +371,3 @@ export const useVotingStore = defineStore('voting', () => {
handleVoteUpdate, handleVoteUpdate,
}; };
}); });

View File

@@ -16,14 +16,13 @@ const programs = computed(() => {
// If no programs from server, show default list // If no programs from server, show default list
if (votingStore.programs.length === 0) { if (votingStore.programs.length === 0) {
return [ return [
{ id: 'p1', name: '龙腾四海', teamName: '市场部', order: 1, status: 'pending' as const, votes: 0 }, { id: 'p1', name: '龙腾四海', teamName: '市场部', performer: '张三、李四', order: 1, remark: '赞美节目如琥珀般凝固了某个经典、美好、闪光的瞬间,适合怀旧、温情、经典的表演。', status: 'pending' as const, votes: 0 },
{ id: 'p2', name: '金马奔腾', teamName: '技术部', order: 2, status: 'pending' as const, votes: 0 }, { id: 'p2', name: '金马奔腾', teamName: '技术部', performer: '王五、赵六', order: 2, remark: '强调节目留下了值得回味的\'声音\',可以是歌声、朗诵声,甚至是幽默的回响。适配唱、诵、幽默类节目。', status: 'pending' as const, votes: 0 },
{ id: 'p3', name: '春风得意', teamName: '人力资源部', order: 3, status: 'pending' as const, votes: 0 }, { id: 'p3', name: '春风得意', teamName: '人力资源部', performer: '刘七、陈八', order: 3, remark: '赞美节目引发了跨越时代的共鸣,无论是家国情怀、青春记忆还是职场幽默。适配有感染力、引发集体回忆的节目。', status: 'pending' as const, votes: 0 },
{ id: 'p4', name: '鸿运当头', teamName: '财务部', order: 4, status: 'pending' as const, votes: 0 }, { id: 'p4', name: '鸿运当头', teamName: '财务部', performer: '周九、吴十', order: 4, remark: '形容节目用声音和表演编织了一个时代的梦境,画面感强。适配意境优美、故事性强或对唱情歌类节目。', status: 'pending' as const, votes: 0 },
{ id: 'p5', name: '马到成功', teamName: '运营部', order: 5, status: 'pending' as const, votes: 0 }, { id: 'p5', name: '马到成功', teamName: '运营部', performer: '郑十一、冯十二', order: 5, remark: '既指复刻了过去的潮流,也指创造了今晚的潮流。适配活力四射、改编新颖、引领现场气氛的节目。', status: 'pending' as const, votes: 0 },
{ id: 'p6', name: '一马当先', teamName: '产品部', order: 6, status: 'pending' as const, votes: 0 }, { id: 'p6', name: '一马当先', teamName: '产品部', performer: '孙十三、杨十四', order: 6, remark: '强调节目的独特韵味与精心打磨,可以是深情的独唱,也可以是巧妙的改编,突出\'独特\'和\'匠心\'。', status: 'pending' as const, votes: 0 },
{ id: 'p7', name: '万马奔腾', teamName: '设计部', order: 7, status: 'pending' as const, votes: 0 }, { id: 'p7', name: '万马奔腾', teamName: '设计部', performer: '何十五、林十六', order: 7, remark: '赞美节目与\'复古70-80\'主题高度契合,与时代精神同频共振。适配主题鲜明、情怀真挚的集体性节目。', status: 'pending' as const, votes: 0 },
{ id: 'p8', name: '龙马精神', teamName: '销售部', order: 8, status: 'pending' as const, votes: 0 },
]; ];
} }
return votingStore.programs; return votingStore.programs;
@@ -80,10 +79,10 @@ onMounted(() => {
<div class="progress-ring"> <div class="progress-ring">
<svg viewBox="0 0 36 36" class="circular-progress"> <svg viewBox="0 0 36 36" class="circular-progress">
<path class="circle-bg" d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831" /> <path class="circle-bg" d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831" />
<path class="circle-progress" :stroke-dasharray="`${(votingStore.usedTickets.length / 7) * 100}, 100`" <path class="circle-progress" :stroke-dasharray="`${votingStore.totalTicketCount > 0 ? (votingStore.usedTicketCount / votingStore.totalTicketCount) * 100 : 0}, 100`"
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831" /> d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831" />
</svg> </svg>
<span class="progress-text">{{ votingStore.usedTickets.length }}/7</span> <span class="progress-text">{{ votingStore.usedTicketCount }}/{{ votingStore.totalTicketCount }}</span>
</div> </div>
</header> </header>
</div> </div>
@@ -96,6 +95,8 @@ onMounted(() => {
:program-id="program.id" :program-id="program.id"
:program-name="program.name" :program-name="program.name"
:team-name="program.teamName" :team-name="program.teamName"
:performer="(program as any).performer"
:remark="(program as any).remark"
:index="index" :index="index"
:status="program.status" :status="program.status"
:is-current="program.id === votingStore.currentProgramId" :is-current="program.id === votingStore.currentProgramId"

View File

@@ -60,6 +60,8 @@ defineExpose({
:id="program.id" :id="program.id"
:name="program.name" :name="program.name"
:team-name="program.teamName" :team-name="program.teamName"
:performer="(program as any).performer"
:remark="(program as any).remark"
:order="program.order" :order="program.order"
:votes="program.votes" :votes="program.votes"
:stamps="program.stamps" :stamps="program.stamps"
@@ -85,29 +87,26 @@ defineExpose({
display: grid; display: grid;
grid-template-columns: repeat(var(--columns, 4), 1fr); grid-template-columns: repeat(var(--columns, 4), 1fr);
grid-template-rows: repeat(var(--rows, 2), 1fr); grid-template-rows: repeat(var(--rows, 2), 1fr);
gap: 24px; gap: 40px;
padding: 32px; padding: 40px 80px 80px;
width: 100%; width: 100%;
height: 100%; height: 100%;
box-sizing: border-box; box-sizing: border-box;
background: #FDFBF7; background: transparent;
overflow: visible; // Allow shadows and rotation to bleed out if needed
// 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;
// Responsive breakpoints // Responsive breakpoints
@media (max-width: 1200px) { @media (max-width: 1200px) {
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(3, 1fr);
gap: 20px; gap: 30px;
padding: 24px; padding: 30px;
} }
@media (max-width: 900px) { @media (max-width: 900px) {
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
grid-template-rows: auto; grid-template-rows: auto;
gap: 16px; gap: 20px;
padding: 16px; padding: 20px;
overflow-y: auto; overflow-y: auto;
height: auto; height: auto;
min-height: 100%; min-height: 100%;
@@ -115,8 +114,8 @@ defineExpose({
@media (max-width: 500px) { @media (max-width: 500px) {
grid-template-columns: 1fr; grid-template-columns: 1fr;
gap: 12px; gap: 16px;
padding: 12px; padding: 16px;
} }
} }
@@ -126,29 +125,37 @@ defineExpose({
} }
.empty-slot { .empty-slot {
background: rgba(0, 0, 0, 0.02); background: rgba(255, 255, 255, 0.03);
border: 2px dashed #ddd; border: 2px dashed rgba(255, 255, 255, 0.1);
border-radius: 4px; border-radius: 8px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: 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 { .empty-placeholder {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 8px; gap: 12px;
color: #bbb; color: rgba(255, 255, 255, 0.25);
.empty-icon { .empty-icon {
font-size: 32px; font-size: 48px;
opacity: 0.5; opacity: 0.6;
filter: grayscale(1) brightness(0.8);
} }
.empty-text { .empty-text {
font-size: 14px; font-size: 16px;
font-family: 'SimSun', 'Songti SC', serif; font-family: 'Kaiti', 'STKaiti', serif;
letter-spacing: 2px;
} }
} }
</style> </style>

View File

@@ -19,17 +19,26 @@ export interface Props {
id: string; id: string;
name: string; name: string;
teamName: string; teamName: string;
performer?: string; // 表演者
remark?: string; // 节目备注
order: number; order: number;
votes: number; votes: number;
stamps?: VoteStamp[]; stamps?: VoteStamp[];
slogan?: string;
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
slogan: 'With all our passion', remark: 'With all our passion',
stamps: () => [], 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] // Generate zip code display: 2|0|2|6|0|[order]
const zipCodes = computed(() => { const zipCodes = computed(() => {
const orderStr = String(props.order).padStart(1, '0'); const orderStr = String(props.order).padStart(1, '0');
@@ -114,13 +123,13 @@ defineExpose({
</div> </div>
</div> </div>
<!-- Content Row: Left content, Right address --> <!-- Content Row: Left slogan, Right address -->
<div class="content-row"> <div class="content-row">
<!-- Left Side: Title + Slogan --> <!-- Left Side: Title + Slogan -->
<div class="content-left"> <div class="content-left">
<h2 class="program-name">{{ name }}</h2> <h2 class="program-name">{{ name }}</h2>
<div class="slogan-box"> <div class="slogan-box">
<span class="slogan-text">{{ slogan }}</span> <span class="slogan-text">{{ remark }}</span>
</div> </div>
</div> </div>
@@ -129,7 +138,7 @@ defineExpose({
<div class="address-zone"> <div class="address-zone">
<div class="address-line"> <div class="address-line">
<span class="label"></span> <span class="label"></span>
<span class="value">{{ teamName }}</span> <span class="value">{{ senderDisplay }}</span>
</div> </div>
<div class="address-line"> <div class="address-line">
<span class="label"></span> <span class="label"></span>
@@ -152,21 +161,35 @@ defineExpose({
position: relative; position: relative;
width: 100%; width: 100%;
aspect-ratio: 1.5 / 1; aspect-ratio: 1.5 / 1;
background: #FDFBF7; background: #fdfcf0; // Parchment color
border: 1px solid #2c2c2c; border: 1px solid rgba(0, 0, 0, 0.1);
box-shadow: 2px 2px 10px rgba(0, 0, 0, 0.1); box-shadow:
padding: 16px 20px; 0 10px 25px rgba(0, 0, 0, 0.25),
0 2px 5px rgba(0, 0, 0, 0.1);
padding: 20px 24px;
display: flex; display: flex;
flex-direction: column; 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 // Paper texture
&::before { &::before {
content: ''; content: '';
position: absolute; position: absolute;
inset: 0; 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"); background-image: url("https://www.transparenttextures.com/patterns/pinstriped-suit.png");
opacity: 0.03; opacity: 0.05;
pointer-events: none; pointer-events: none;
} }
} }
@@ -181,8 +204,9 @@ defineExpose({
.postmark-wrapper { .postmark-wrapper {
position: absolute; position: absolute;
width: 80px; width: 110px;
height: 80px; height: 110px;
filter: drop-shadow(0 2px 4px rgba(185, 28, 28, 0.2));
&.is-new { &.is-new {
animation: stamp-fly-in 0.6s cubic-bezier(0.175, 0.885, 0.32, 1.275) forwards; animation: stamp-fly-in 0.6s cubic-bezier(0.175, 0.885, 0.32, 1.275) forwards;
@@ -196,11 +220,12 @@ defineExpose({
} }
70% { 70% {
transform: translate(-50%, -50%) scale(0.9); transform: translate(-50%, -50%) scale(0.9);
filter: drop-shadow(0 10px 20px rgba(185, 28, 28, 0.5));
opacity: 1; opacity: 1;
} }
100% { 100% {
transform: translate(-50%, -50%) scale(1); transform: translate(-50%, -50%) scale(1);
opacity: 0.9; opacity: 0.95;
} }
} }
@@ -211,36 +236,37 @@ defineExpose({
align-items: flex-start; align-items: flex-start;
position: relative; position: relative;
z-index: 2; z-index: 2;
margin-bottom: 12px; margin-bottom: 16px;
} }
.zip-codes { .zip-codes {
display: flex; display: flex;
gap: 3px; gap: 4px;
} }
.zip-box { .zip-box {
width: 22px; width: 26px;
height: 26px; height: 32px;
border: 1.5px solid #c41e3a; border: 1.5px solid #b91c1c;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-family: 'Courier New', monospace; font-family: 'Courier New', monospace;
font-size: 13px; font-size: 16px;
font-weight: bold; font-weight: 800;
color: #2c2c2c; color: #1a1a1a;
background: #fff; background: #fff;
box-shadow: inset 0 1px 3px rgba(0,0,0,0.1);
} }
.stamp-box { .stamp-box {
width: 90px; width: 100px;
height: 90px; height: 100px;
display: flex; padding: 4px;
align-items: center; background: white;
justify-content: center; border: 1px solid #e5e7eb;
background: transparent; box-shadow: 0 2px 5px rgba(0,0,0,0.1);
border: 1px solid #ddd; transform: rotate(2deg);
} }
.stamp-image { .stamp-image {
@@ -253,67 +279,75 @@ defineExpose({
.content-row { .content-row {
flex: 1; flex: 1;
display: flex; display: flex;
flex-direction: row;
justify-content: space-between; justify-content: space-between;
gap: 20px;
position: relative; position: relative;
z-index: 2; z-index: 2;
min-height: 0; // Prevent overflow
} }
// Left Side: Title + Slogan // Left Side: Title + Slogan
.content-left { .content-left {
flex: 1; flex: 1.2;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: flex-start; gap: 12px;
padding-right: 20px; min-width: 0; // Allow shrinking for text wrap
} }
.program-name { .program-name {
font-family: 'SimSun', 'Songti SC', 'STSong', serif; font-family: 'SimSun', 'Songti SC', 'STSong', serif;
font-size: 28px; font-size: 32px;
font-weight: bold; font-weight: 900;
color: #c41e3a; color: #b91c1c;
margin: 0 0 12px 0; margin: 0;
letter-spacing: 6px; letter-spacing: 6px;
text-shadow: 0.5px 0.5px 1px rgba(0,0,0,0.1);
word-break: break-all;
} }
.slogan-box { .slogan-box {
display: inline-block; display: inline-block;
border: 1px solid #ccc; padding: 8px 14px;
padding: 6px 14px;
align-self: flex-start; 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 { .slogan-text {
font-family: 'Georgia', 'Times New Roman', serif; font-family: 'Kaiti', 'STKaiti', serif;
font-size: 12px; font-size: 15px;
font-style: italic; color: #4b5563;
color: #666; line-height: 1.5;
word-break: break-all;
display: block;
} }
// Right Side: Address // Right Side: Address
.content-right { .content-right {
flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: flex-end; justify-content: flex-start; // Start from top area
align-items: flex-start; min-width: 200px;
min-width: 120px; padding-top: 15%; // Approximately the golden ratio point of the content height
padding-bottom: 30px; // Ensure space for vote count
} }
.address-zone { .address-zone {
text-align: left;
border-bottom: 1px solid #999;
padding-bottom: 8px;
margin-left: -25px;
margin-top: -40px;
width: 100%; width: 100%;
} }
.address-line { .address-line {
display: flex; display: flex;
align-items: baseline; align-items: baseline;
gap: 6px; gap: 8px;
margin-bottom: 6px; margin-bottom: 18px; // Slightly tighter for more refined look
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
padding-bottom: 3px;
&:last-child { &:last-child {
margin-bottom: 0; margin-bottom: 0;
@@ -321,36 +355,42 @@ defineExpose({
.label { .label {
font-family: 'SimSun', 'Songti SC', serif; font-family: 'SimSun', 'Songti SC', serif;
font-size: 11px; font-size: 14px;
color: #666; color: #6b7280;
min-width: 24px; min-width: 30px;
} }
.value { .value {
font-family: 'Kaiti', 'STKaiti', serif; font-family: 'Kaiti', 'STKaiti', serif;
font-size: 13px; font-size: 18px;
color: #2c2c2c; color: #1f2937;
flex: 1;
line-height: 1.2;
transform: rotate(-0.5deg);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
} }
// Footer Zone // Footer Zone
.footer-zone { .footer-zone {
display: flex; position: absolute;
justify-content: flex-end; right: 15px;
align-items: center; bottom: 15px;
margin-top: auto; z-index: 5;
padding-top: 8px;
position: relative;
z-index: 2;
.vote-count { .vote-count {
font-family: 'SimSun', 'Songti SC', serif; font-family: 'SimSun', 'Songti SC', serif;
font-size: 14px; font-size: 16px;
font-weight: bold; font-weight: 800;
color: #c41e3a; color: #b91c1c;
padding: 4px 12px; padding: 6px 14px;
background: rgba(196, 30, 58, 0.08); background: white;
border: 1px solid rgba(196, 30, 58, 0.2); border: 1.5px solid #b91c1c;
border-radius: 2px;
box-shadow: 3px 3px 0 rgba(185, 28, 28, 0.1);
white-space: nowrap;
} }
} }
</style> </style>

View File

@@ -90,8 +90,8 @@ const currentDate = computed(() => {
.postmark { .postmark {
position: relative; position: relative;
width: 70px; width: 100%;
height: 70px; height: 100%;
// Multiply blend mode for realism // Multiply blend mode for realism
mix-blend-mode: multiply; mix-blend-mode: multiply;
animation: stamp-reveal 0.3s ease-out forwards; animation: stamp-reveal 0.3s ease-out forwards;
@@ -108,9 +108,9 @@ const currentDate = computed(() => {
font-family: 'Kaiti', 'STKaiti', serif; font-family: 'Kaiti', 'STKaiti', serif;
font-weight: bold; font-weight: bold;
&.top { font-size: 11px; } &.top { font-size: 14px; }
&.date { font-size: 10px; letter-spacing: 0.5px; } &.date { font-size: 12px; letter-spacing: 0.5px; }
&.bottom { font-size: 10px; } &.bottom { font-size: 12px; }
} }
.grunge-overlay { .grunge-overlay {

View File

@@ -37,6 +37,7 @@ export interface VoteEvent {
candidateId: string; candidateId: string;
category: string; category: string;
totalVotes: number; totalVotes: number;
programVotes?: number;
delta: number; delta: number;
timestamp: number; timestamp: number;
} }

View File

@@ -36,6 +36,7 @@ export const useAdminStore = defineStore('admin', () => {
const programs = ref<Array<{ id: string; name: string; teamName: string; order: number; status: string; votes: number; stamps: any[] }>>([]); const programs = ref<Array<{ id: string; name: string; teamName: string; order: number; status: string; votes: number; stamps: any[] }>>([]);
const allowLateCatch = ref(true); const allowLateCatch = ref(true);
const currentProgramId = ref<string | null>(null); const currentProgramId = ref<string | null>(null);
const awards = ref<any[]>([]);
// Lottery State // Lottery State
const lotteryRound = ref<LotteryRound>(1); const lotteryRound = ref<LotteryRound>(1);
@@ -206,6 +207,21 @@ export const useAdminStore = defineStore('admin', () => {
syncFromServer(state); 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; socket.value = socketInstance as GalaSocket;
} }
@@ -215,6 +231,7 @@ export const useAdminStore = defineStore('admin', () => {
votingPaused.value = state.voting.subPhase === 'PAUSED'; votingPaused.value = state.voting.subPhase === 'PAUSED';
totalVotes.value = state.voting.totalVotes; totalVotes.value = state.voting.totalVotes;
programs.value = state.voting.programs || []; programs.value = state.voting.programs || [];
awards.value = state.voting.awards || [];
allowLateCatch.value = state.voting.allowLateCatch ?? true; allowLateCatch.value = state.voting.allowLateCatch ?? true;
currentProgramId.value = state.voting.currentProgramId || null; currentProgramId.value = state.voting.currentProgramId || null;
lotteryRound.value = state.lottery.round; lotteryRound.value = state.lottery.round;
@@ -404,6 +421,7 @@ export const useAdminStore = defineStore('admin', () => {
programs, programs,
allowLateCatch, allowLateCatch,
currentProgramId, currentProgramId,
awards,
lotteryRound, lotteryRound,
lotterySubPhase, lotterySubPhase,
stormStartedAt, stormStartedAt,

View File

@@ -19,12 +19,27 @@ const prizeConfigLoading = ref(false);
const prizeConfigSaving = ref(false); const prizeConfigSaving = ref(false);
const editingPrizes = ref<PrizeConfig[]>([]); const editingPrizes = ref<PrizeConfig[]>([]);
async function readJsonSafe(res: Response): Promise<any> {
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 // Load prize configuration from server
async function loadPrizeConfig() { async function loadPrizeConfig() {
prizeConfigLoading.value = true; prizeConfigLoading.value = true;
try { try {
const res = await fetch('/api/admin/prizes'); 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) { if (data.success && data.data?.prizes) {
editingPrizes.value = data.data.prizes.map((p: any) => ({ ...p })); editingPrizes.value = data.data.prizes.map((p: any) => ({ ...p }));
} }
@@ -44,7 +59,10 @@ async function savePrizeConfig() {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prizes: editingPrizes.value }), 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) { if (data.success) {
showPrizeConfig.value = false; showPrizeConfig.value = false;
} else { } else {
@@ -98,7 +116,10 @@ async function importParticipants() {
body: formData, 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: {...} } // 确保保留 success 字段,后端返回结构为 { success: true, data: {...} }
importResult.value = { importResult.value = {
success: data.success ?? data.data?.success ?? false, success: data.success ?? data.data?.success ?? false,
@@ -132,7 +153,10 @@ const tagLabels: Record<string, string> = {
async function loadParticipants() { async function loadParticipants() {
try { try {
const response = await fetch('/api/admin/participants'); 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) { if (data.success && data.data?.count > 0) {
importResult.value = { importResult.value = {
success: true, success: true,
@@ -273,7 +297,7 @@ function showEntryQR() {
function hideQR() { function hideQR() {
const socket = admin.getSocket(); const socket = admin.getSocket();
if (socket) { if (socket) {
socket.emit('display:hide_qr' as any, ); socket.emit('display:hide_qr' as any, {});
} }
} }

View File

@@ -39,12 +39,16 @@ function handleStateSync(state: AdminState) {
} }
// Handle real-time vote updates // 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) { if (renderer) {
renderer.updateVotes(data.candidateId, data.totalVotes); if (typeof data.programVotes === 'number') {
renderer.updateVotes(data.candidateId, data.programVotes);
}
} }
// Update total votes // Update total votes
totalVotes.value = data.totalVotes; if (typeof data.totalVotes === 'number') {
totalVotes.value = data.totalVotes;
}
} }
onMounted(async () => { onMounted(async () => {

View File

@@ -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); const program = programs.value.find(p => p.id === data.candidateId);
if (program) { 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 (data.stamp) {
if (!program.stamps) program.stamps = []; if (!program.stamps) program.stamps = [];
program.stamps.push(data.stamp); 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); totalVotes.value = programs.value.reduce((sum, p) => sum + p.votes, 0);
} }
} }
@@ -99,15 +107,8 @@ onUnmounted(() => {
</main> </main>
</div> </div>
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>
// Philatelic postcard theme colors @use '../assets/styles/variables.scss' as *;
$color-paper: #FDFBF7;
$color-ink: #2c2c2c;
$color-red: #c41e3a;
$color-gold: #d4af37;
$color-text-muted: #666;
.live-voting-view { .live-voting-view {
width: 100%; width: 100%;
@@ -116,51 +117,65 @@ $color-text-muted: #666;
flex-direction: column; flex-direction: column;
position: relative; position: relative;
overflow: hidden; overflow: hidden;
background: $color-paper; background: radial-gradient(circle at center, #b91c1c 0%, #7f1d1d 100%);
color: $color-ink; color: #fff;
// 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;
z-index: 1;
}
} }
.header { .header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 20px 40px; padding: 20px 60px;
position: relative; position: relative;
z-index: 100; z-index: 100;
background: rgba(255, 255, 255, 0.9); background: rgba(0, 0, 0, 0.2);
border-bottom: 1px solid #ddd; backdrop-filter: blur(10px);
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05); border-bottom: 1px solid rgba($color-gold, 0.2);
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.3);
flex-wrap: wrap; flex-wrap: wrap;
gap: 12px; gap: 20px;
.back-btn { .back-btn {
background: white; background: rgba(255, 255, 255, 0.1);
border: 1px solid $color-ink; border: 1px solid rgba($color-gold, 0.5);
color: $color-ink; color: $color-gold;
padding: 8px 16px; padding: 10px 24px;
border-radius: 4px; border-radius: 30px;
cursor: pointer; cursor: pointer;
font-size: 14px; font-size: 14px;
transition: all 0.2s; transition: all $transition-fast;
&:hover { &:hover {
background: $color-ink; background: rgba($color-gold, 0.2);
color: white; transform: translateX(-4px);
} }
} }
.title { .title {
font-size: 32px; font-size: 48px;
font-family: 'SimSun', 'Songti SC', serif; font-family: 'SimSun', 'Songti SC', serif;
font-weight: bold; font-weight: 800;
color: $color-red; color: #fff;
letter-spacing: 8px; letter-spacing: 12px;
margin: 0;
text-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
} }
.header-right { .header-right {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 24px; gap: 32px;
flex-wrap: wrap; flex-wrap: wrap;
} }
@@ -168,110 +183,65 @@ $color-text-muted: #666;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: flex-end; align-items: flex-end;
background: white; background: rgba(0, 0, 0, 0.3);
padding: 8px 16px; padding: 10px 24px;
border-radius: 8px; border-radius: 12px;
border: 1px solid #ddd; border: 1px solid rgba($color-gold, 0.3);
box-shadow: inset 0 0 15px rgba($color-gold, 0.1);
.counter-label { .counter-label {
font-size: 12px; font-size: 12px;
color: $color-text-muted; color: rgba(255, 255, 255, 0.6);
margin-bottom: 2px; margin-bottom: 4px;
letter-spacing: 2px;
} }
.counter-value { .counter-value {
font-size: 28px; font-size: 36px;
font-weight: bold; font-weight: bold;
color: $color-red; color: $color-gold;
line-height: 1; line-height: 1;
font-family: 'Courier New', monospace; font-family: 'Courier New', monospace;
text-shadow: 0 0 12px rgba($color-gold, 0.5);
} }
} }
.status { .status {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 16px;
.status-badge { .status-badge {
padding: 6px 16px; padding: 8px 20px;
border-radius: 4px; border-radius: 20px;
font-size: 14px; font-size: 14px;
font-weight: 500; font-weight: 600;
background: #f5f5f5; background: rgba(255, 255, 255, 0.1);
color: $color-text-muted; color: rgba(255, 255, 255, 0.8);
border: 1px solid #ddd; border: 1px solid rgba(255, 255, 255, 0.2);
&.open { &.open {
background: rgba(34, 197, 94, 0.1); background: rgba(74, 222, 128, 0.1);
color: #16a34a; color: #4ade80;
border-color: rgba(34, 197, 94, 0.3); border-color: rgba(74, 222, 128, 0.4);
box-shadow: 0 0 15px rgba(74, 222, 128, 0.1);
} }
} }
.online-count { .online-count {
font-size: 14px; font-size: 14px;
color: $color-text-muted; color: rgba(255, 255, 255, 0.7);
} }
.connection-dot { .connection-dot {
width: 8px; width: 10px;
height: 8px; height: 10px;
border-radius: 50%; border-radius: 50%;
background: #ccc; background: #475569;
&.connected { &.connected {
background: #22c55e; background: #4ade80;
} box-shadow: 0 0 10px #4ade80;
}
}
// Responsive adjustments
@media (max-width: 900px) {
padding: 16px 20px;
.title {
font-size: 24px;
letter-spacing: 4px;
}
.vote-counter .counter-value {
font-size: 22px;
}
}
@media (max-width: 600px) {
padding: 12px 16px;
justify-content: center;
.title {
font-size: 20px;
letter-spacing: 2px;
width: 100%;
text-align: center;
order: -1;
}
.back-btn {
padding: 6px 12px;
font-size: 12px;
}
.header-right {
gap: 12px;
}
.vote-counter {
padding: 6px 12px;
.counter-value {
font-size: 18px;
}
}
.status {
.online-count {
display: none;
} }
} }
} }
@@ -279,6 +249,25 @@ $color-text-muted: #666;
.grid-container { .grid-container {
flex: 1; flex: 1;
overflow: hidden; overflow: visible;
position: relative;
z-index: 2;
}
// Responsive adjustments
@media (max-width: 1200px) {
.header {
padding: 20px 40px;
.title { font-size: 36px; letter-spacing: 8px; }
}
}
@media (max-width: 900px) {
.header {
padding: 16px 20px;
justify-content: center;
.title { width: 100%; text-align: center; margin-bottom: 10px; }
.header-right { width: 100%; justify-content: space-between; }
}
} }
</style> </style>

View File

@@ -11,28 +11,19 @@ function goBack() {
router.push('/'); router.push('/');
} }
// Award type labels // Compute award statistics grouped by award config from server
const awardLabels: Record<string, string> = {
creative: '最佳创意奖',
visual: '最佳视觉奖',
atmosphere: '最佳气氛奖',
performance: '最佳表演奖',
teamwork: '最佳团队奖',
popularity: '最受欢迎奖',
potential: '最具潜力奖',
};
// Compute award statistics grouped by award type
const awardResults = computed(() => { const awardResults = computed(() => {
const results: Array<{ const results: Array<{
awardType: string; awardType: string;
awardName: string; awardName: string;
remark: string;
programs: Array<{ id: string; name: string; teamName: string; votes: number; percentage: number }>; programs: Array<{ id: string; name: string; teamName: string; votes: number; percentage: number }>;
totalVotes: number; totalVotes: number;
}> = []; }> = [];
// Process each award type // Process each award from admin store
TICKET_TYPES.forEach(awardType => { admin.awards.forEach(award => {
const awardType = award.id;
const programStats: Map<string, { name: string; teamName: string; votes: number }> = new Map(); const programStats: Map<string, { name: string; teamName: string; votes: number }> = new Map();
// Aggregate stamps by program for this award type // Aggregate stamps by program for this award type
@@ -67,7 +58,9 @@ const awardResults = computed(() => {
results.push({ results.push({
awardType, awardType,
awardName: awardLabels[awardType] || awardType, awardName: award.name,
awardIcon: award.icon,
remark: award.remark || '',
programs, programs,
totalVotes, totalVotes,
}); });
@@ -101,6 +94,8 @@ onMounted(() => {
<h2 class="category-name">{{ award.awardName }}</h2> <h2 class="category-name">{{ award.awardName }}</h2>
<span class="total-votes">{{ award.totalVotes }} </span> <span class="total-votes">{{ award.totalVotes }} </span>
</div> </div>
<div class="award-stamp">{{ award.awardIcon }}</div>
<p v-if="award.remark" class="category-remark">{{ award.remark }}</p>
<div class="results-list"> <div class="results-list">
<div v-if="award.programs.length === 0" class="no-results"> <div v-if="award.programs.length === 0" class="no-results">
@@ -135,58 +130,77 @@ onMounted(() => {
.vote-results-view { .vote-results-view {
width: 100%; width: 100%;
height: 100%; height: 100%;
background: $color-bg-gradient; background: radial-gradient(circle at center, #b91c1c 0%, #7f1d1d 100%);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: auto; 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 { .header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 24px 40px; padding: 30px 60px;
flex-shrink: 0; flex-shrink: 0;
z-index: 10;
.back-btn { .back-btn {
background: none; background: rgba(255, 255, 255, 0.1);
border: 1px solid $color-gold; border: 1px solid rgba($color-gold, 0.5);
color: $color-gold; color: $color-gold;
padding: 8px 16px; padding: 10px 20px;
border-radius: 8px; border-radius: 30px;
cursor: pointer; cursor: pointer;
font-size: 14px; font-size: 14px;
transition: all $transition-fast; transition: all $transition-fast;
backdrop-filter: blur(5px);
&:hover { &:hover {
background: rgba($color-gold, 0.1); background: rgba($color-gold, 0.2);
transform: translateX(-4px);
} }
} }
.title { .title {
font-size: 36px; font-size: 48px;
font-weight: bold; font-weight: 800;
letter-spacing: 4px;
text-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
} }
.status-indicator { .status-indicator {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 10px;
font-size: 14px; 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 { .dot {
width: 8px; width: 10px;
height: 8px; height: 10px;
border-radius: 50%; border-radius: 50%;
&.online { &.online {
background: #22c55e; background: #4ade80;
box-shadow: 0 0 8px rgba(34, 197, 94, 0.5); box-shadow: 0 0 12px #4ade80;
} }
&.offline { &.offline {
background: #666; background: #94a3b8;
} }
} }
} }
@@ -195,112 +209,184 @@ onMounted(() => {
.results-grid { .results-grid {
flex: 1; flex: 1;
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
gap: 20px; gap: 40px;
padding: 20px 40px 40px; padding: 20px 60px 80px;
align-content: start; align-content: start;
} }
.category-card { .category-card {
background: rgba(255, 255, 255, 0.05); position: relative;
border: 1px solid rgba($color-gold, 0.3); background: #fdfcf0; // Parchment color
border-radius: 12px; background-image: radial-gradient(#e5e7eb 0.5px, transparent 0.5px);
padding: 20px; 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 { .category-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: flex-start;
margin-bottom: 16px; margin-bottom: 20px;
padding-bottom: 12px; 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 { .award-stamp {
font-size: 20px; position: absolute;
color: $color-gold; top: 20px;
font-weight: 600; right: 20px;
margin: 0; font-size: 64px;
opacity: 0.1;
transform: rotate(20deg);
filter: grayscale(1);
pointer-events: none;
z-index: 0;
user-select: none;
} }
.total-votes { .category-remark {
font-size: 14px; font-size: 16px;
color: rgba(255, 255, 255, 0.5); color: #4b5563;
background: rgba($color-gold, 0.1); font-family: 'Kaiti', 'STKaiti', serif;
padding: 4px 10px; line-height: 1.6;
border-radius: 12px; 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 { .results-list {
flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 10px; gap: 12px;
} }
.no-results { .no-results {
padding: 20px; flex: 1;
text-align: center; display: flex;
color: rgba(255, 255, 255, 0.4); align-items: center;
justify-content: center;
color: #94a3b8;
font-style: italic; font-style: italic;
font-size: 18px;
font-family: 'Kaiti', serif;
letter-spacing: 2px;
border: 2px dashed #e2e8f0;
border-radius: 8px;
} }
.result-item { .result-item {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 15px;
padding: 10px 12px; padding: 12px 16px;
background: rgba(255, 255, 255, 0.03); background: white;
border-radius: 8px; border: 1px solid #e5e7eb;
border-radius: 6px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
&.winner { &.winner {
background: rgba($color-gold, 0.1); background: #fffbef;
border: 1px solid $color-gold; border: 1.5px solid #b91c1c;
box-shadow: 0 4px 6px rgba(185, 28, 28, 0.1);
.rank { .rank {
background: $color-gold; background: #b91c1c;
color: #000; color: white;
} }
.name { .name {
color: $color-gold; color: #b91c1c;
font-weight: bold;
} }
.bar { .bar {
background: linear-gradient(90deg, $color-gold-dark, $color-gold); background: linear-gradient(90deg, #991b1b, #ef4444);
} }
} }
.rank { .rank {
width: 26px; width: 28px;
height: 26px; height: 28px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: rgba(255, 255, 255, 0.1); background: #f1f5f9;
color: #64748b;
border-radius: 50%; border-radius: 50%;
font-size: 13px; font-size: 14px;
font-weight: bold; font-weight: 800;
flex-shrink: 0; flex-shrink: 0;
} }
.info { .info {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-width: 80px; min-width: 100px;
max-width: 100px; max-width: 140px;
} }
.name { .name {
font-size: 14px; font-size: 16px;
color: $color-text-light; color: #1e293b;
font-weight: 600;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.team { .team {
font-size: 11px; font-size: 12px;
color: rgba(255, 255, 255, 0.4); color: #64748b;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
@@ -308,51 +394,71 @@ onMounted(() => {
.bar-container { .bar-container {
flex: 1; flex: 1;
height: 18px; height: 14px;
background: rgba(255, 255, 255, 0.1); background: #f8fafc;
border-radius: 9px; border: 1px solid #f1f5f9;
border-radius: 10px;
overflow: hidden; overflow: hidden;
.bar { .bar {
height: 100%; height: 100%;
background: linear-gradient(90deg, $color-primary-dark, $color-primary); background: linear-gradient(90deg, #475569, #94a3b8);
border-radius: 9px; border-radius: 10px;
transition: width 1s ease; transition: width 1s cubic-bezier(0.16, 1, 0.3, 1);
} }
} }
.votes { .votes {
width: 50px; width: 55px;
text-align: right; text-align: right;
font-size: 14px; font-size: 16px;
color: $color-text-muted; color: #1e293b;
font-weight: 800;
font-family: 'monospace';
flex-shrink: 0; 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 // Responsive
@media (max-width: 1200px) {
.results-grid {
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
padding: 20px 30px;
}
}
@media (max-width: 768px) { @media (max-width: 768px) {
.header { .header {
padding: 16px 20px; padding: 20px;
flex-direction: column;
gap: 15px;
.title { .title { font-size: 32px; }
font-size: 24px;
}
} }
.results-grid { .results-grid {
grid-template-columns: 1fr; padding: 10px 20px;
padding: 16px 20px; gap: 20px;
gap: 16px;
} }
.category-card { .category-card {
padding: 16px; min-height: auto;
padding: 20px;
.category-name {
font-size: 18px;
}
} }
} }
</style> </style>

View File

@@ -4,49 +4,108 @@
"id": "p1", "id": "p1",
"name": "龙腾四海", "name": "龙腾四海",
"teamName": "市场部", "teamName": "市场部",
"order": 1 "performer": "张三、李四",
"order": 1,
"remark": "大型民族舞表演,融合了古典与现代元素,展现龙的精神。"
}, },
{ {
"id": "p2", "id": "p2",
"name": "金马奔腾", "name": "金马奔腾",
"teamName": "技术部", "teamName": "技术部",
"order": 2 "performer": "王五、赵六",
"order": 2,
"remark": "动感的现代舞,充满力量与节奏感。"
}, },
{ {
"id": "p3", "id": "p3",
"name": "春风得意", "name": "春风得意",
"teamName": "人力资源部", "teamName": "人力资源部",
"order": 3 "performer": "刘七、陈八",
"order": 3,
"remark": "温馨的情景剧,讲述了职场中的温暖瞬间。"
}, },
{ {
"id": "p4", "id": "p4",
"name": "鸿运当头", "name": "鸿运当头",
"teamName": "财务部", "teamName": "财务部",
"order": 4 "performer": "周九、吴十",
"order": 4,
"remark": "精彩的杂技表演,寓意新年鸿运连连。"
}, },
{ {
"id": "p5", "id": "p5",
"name": "马到成功", "name": "马到成功",
"teamName": "运营部", "teamName": "运营部",
"order": 5 "performer": "郑十一、冯十二",
"order": 5,
"remark": "热血沸腾的多人合唱,充满了前进的动力。"
}, },
{ {
"id": "p6", "id": "p6",
"name": "一马当先", "name": "一马当先",
"teamName": "产品部", "teamName": "产品部",
"order": 6 "performer": "孙十三、杨十四",
"order": 6,
"remark": "极具创意的光影秀,探索未来科技的可能。"
}, },
{ {
"id": "p7", "id": "p7",
"name": "万马奔腾", "name": "万马奔腾",
"teamName": "设计部", "teamName": "设计部",
"order": 7 "performer": "何十五、林十六",
"order": 7,
"remark": "大合唱,展现团队的凝聚力和向心力。"
}
],
"awards": [
{
"id": "creative",
"name": "时光琥珀奖",
"icon": "🏆",
"order": 1,
"remark": "赞美节目如琥珀般凝固了某个经典、美好、闪光的瞬间,适合怀旧、温情、经典的表演。"
}, },
{ {
"id": "p8", "id": "visual",
"name": "龙马精神", "name": "岁月留声奖",
"teamName": "销售部", "icon": "🎵",
"order": 8 "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": { "settings": {

View File

@@ -5,7 +5,7 @@
-- 1. Each user has 7 distinct tickets (creative, visual, etc.) -- 1. Each user has 7 distinct tickets (creative, visual, etc.)
-- 2. Each ticket can only be assigned to ONE program -- 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) -- 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[1] = vote:user:{userId}:tickets (Hash)
-- KEYS[2] = vote:user:{userId}:programs (Set) -- 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 already_voted_program = redis.call('SISMEMBER', user_programs_key, program_id)
local current_ticket_program = redis.call('HGET', user_tickets_key, ticket_type) local current_ticket_program = redis.call('HGET', user_tickets_key, ticket_type)
-- Case: User trying to vote same program with same ticket (no-op) -- Case: User already used this ticket (no re-vote allowed)
if current_ticket_program == program_id then if current_ticket_program and current_ticket_program ~= false then
redis.call('DEL', lock_key) redis.call('DEL', lock_key)
return cjson.encode({ return cjson.encode({
success = true, success = false,
message = 'Already voted for this program with this ticket', error = 'ALREADY_VOTED',
program_id = program_id, message = 'You already used this ticket'
ticket_type = ticket_type,
is_duplicate = true
}) })
end end
@@ -71,25 +69,7 @@ if already_voted_program == 1 and current_ticket_program ~= program_id then
}) })
end end
-- Step 3: If this ticket was used before, revoke the old vote -- Step 3: Cast the new 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
-- 4a: Set the ticket assignment -- 4a: Set the ticket assignment
redis.call('HSET', user_tickets_key, ticket_type, program_id) redis.call('HSET', user_tickets_key, ticket_type, program_id)
@@ -111,8 +91,7 @@ local vote_record = cjson.encode({
user_id = user_id, user_id = user_id,
program_id = program_id, program_id = program_id,
ticket_type = ticket_type, ticket_type = ticket_type,
timestamp = timestamp, timestamp = timestamp
revoked_program = old_program_id or nil
}) })
redis.call('RPUSH', sync_queue_key, vote_record) redis.call('RPUSH', sync_queue_key, vote_record)
@@ -123,7 +102,5 @@ return cjson.encode({
success = true, success = true,
program_id = program_id, program_id = program_id,
ticket_type = ticket_type, ticket_type = ticket_type,
new_count = new_count, new_count = new_count
revoked = revoked,
revoked_program = old_program_id or nil
}) })

View File

@@ -7,6 +7,7 @@
-- KEYS[4] = leaderboard:{category} -- KEYS[4] = leaderboard:{category}
-- KEYS[5] = sync:queue:votes -- KEYS[5] = sync:queue:votes
-- KEYS[6] = lock:vote:{user_id}:{category} -- KEYS[6] = lock:vote:{user_id}:{category}
-- KEYS[7] = vote:user:{userId}:tickets (Hash)
-- --
-- ARGV[1] = candidate_id -- ARGV[1] = candidate_id
-- ARGV[2] = user_id -- ARGV[2] = user_id
@@ -22,6 +23,7 @@ local category_voters_key = KEYS[3]
local leaderboard_key = KEYS[4] local leaderboard_key = KEYS[4]
local sync_queue_key = KEYS[5] local sync_queue_key = KEYS[5]
local lock_key = KEYS[6] local lock_key = KEYS[6]
local user_tickets_key = KEYS[7]
local candidate_id = ARGV[1] local candidate_id = ARGV[1]
local user_id = ARGV[2] 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 -- 4b: Add category to user's voted categories
redis.call('SADD', user_categories_key, category) 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 -- 4c: Add user to category's voters
redis.call('SADD', category_voters_key, user_id) redis.call('SADD', category_voters_key, user_id)

View File

@@ -20,6 +20,7 @@ import type {
VoteStamp, VoteStamp,
} from '@gala/shared/types'; } from '@gala/shared/types';
import { INITIAL_ADMIN_STATE, PRIZE_CONFIG } 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'; const ADMIN_STATE_KEY = 'gala:admin:state';
@@ -39,6 +40,17 @@ class AdminService extends EventEmitter {
const savedState = await redis.get(ADMIN_STATE_KEY); const savedState = await redis.get(ADMIN_STATE_KEY);
if (savedState) { if (savedState) {
const parsed = JSON.parse(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 // Deep merge to ensure new fields have defaults
this.state = { this.state = {
...INITIAL_ADMIN_STATE, ...INITIAL_ADMIN_STATE,
@@ -46,10 +58,9 @@ class AdminService extends EventEmitter {
voting: { voting: {
...INITIAL_ADMIN_STATE.voting, ...INITIAL_ADMIN_STATE.voting,
...parsed.voting, ...parsed.voting,
// Ensure programs always has default values from config service // 始终从配置服务加载最新的 programs 和 awards
programs: parsed.voting?.programs?.length > 0 programs: mergedPrograms,
? parsed.voting.programs awards: programConfigService.getAwards(),
: programConfigService.getVotingPrograms(),
}, },
lottery: { lottery: {
...INITIAL_ADMIN_STATE.lottery, ...INITIAL_ADMIN_STATE.lottery,
@@ -62,10 +73,24 @@ class AdminService extends EventEmitter {
}; };
logger.info('Admin state restored from Redis (merged with defaults)'); logger.info('Admin state restored from Redis (merged with defaults)');
} else { } 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(); await this.saveState();
logger.info('Admin state initialized with defaults'); logger.info('Admin state initialized with defaults');
} }
// Sync actual vote counts from Redis (VotingEngine is source of truth)
await this.syncVotesFromRedis();
// 从配置文件刷新当前轮次的奖项信息 // 从配置文件刷新当前轮次的奖项信息
await this.refreshPrizeFromConfig(); await this.refreshPrizeFromConfig();
} catch (error) { } catch (error) {
@@ -297,12 +322,30 @@ class AdminService extends EventEmitter {
/** /**
* Add a vote stamp to a program (for display on big screen) * 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); const program = this.state.voting.programs.find(p => p.id === programId);
if (!program) { if (!program) {
return { success: false }; 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 now = new Date();
const dateStr = `${now.getFullYear()}.${String(now.getMonth() + 1).padStart(2, '0')}.${String(now.getDate()).padStart(2, '0')}`; 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 = []; if (!program.stamps) program.stamps = [];
program.stamps.push(stamp); program.stamps.push(stamp);
program.votes++; program.votes++;
this.state.voting.totalVotes++; if (!wasReplacement) {
this.state.voting.totalVotes++;
}
await this.saveState(); 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 { try {
if (scope === 'all' || scope === 'voting') { if (scope === 'all' || scope === 'voting') {
const programSettings = programConfigService.getSettings();
this.state.voting = { this.state.voting = {
...INITIAL_ADMIN_STATE.voting, ...INITIAL_ADMIN_STATE.voting,
programs: programConfigService.getVotingPrograms(), programs: programConfigService.getVotingPrograms(),
awards: programConfigService.getAwards(),
allowLateCatch: programSettings.allowLateCatch,
}; };
// Clear voting data in Redis // Clear voting data in Redis
await redis.del('gala:votes:*'); await this.clearVotingRedisData();
} }
if (scope === 'all' || scope === 'lottery') { if (scope === 'all' || scope === 'lottery') {
@@ -577,6 +632,66 @@ class AdminService extends EventEmitter {
this.state.voting.totalVotes = count; this.state.voting.totalVotes = count;
await this.saveState(); await this.saveState();
} }
/**
* Sync vote counts from Redis (source of truth) to local state
*/
async syncVotesFromRedis(): Promise<void> {
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<void> {
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(); export const adminService = new AdminService();

View File

@@ -18,6 +18,15 @@ export interface ProgramConfig {
id: string; id: string;
name: string; name: string;
teamName: string; teamName: string;
performer?: string; // 表演者
order: number;
remark?: string; // 节目备注
}
export interface AwardConfig {
id: string;
name: string;
icon: string;
order: number; order: number;
} }
@@ -28,6 +37,7 @@ export interface ProgramSettings {
export interface ProgramConfigFile { export interface ProgramConfigFile {
programs: ProgramConfig[]; programs: ProgramConfig[];
awards: AwardConfig[];
settings: ProgramSettings; settings: ProgramSettings;
} }
@@ -49,8 +59,19 @@ class ProgramConfigService {
this.config = JSON.parse(content); this.config = JSON.parse(content);
logger.info({ logger.info({
programCount: this.config?.programs.length, programCount: this.config?.programs.length,
awardCount: this.config?.awards?.length || 0,
configPath: this.configPath configPath: this.configPath
}, 'Program config loaded'); }, '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 { } else {
logger.warn({ configPath: this.configPath }, 'Program config file not found, using defaults'); logger.warn({ configPath: this.configPath }, 'Program config file not found, using defaults');
this.config = this.getDefaults(); this.config = this.getDefaults();
@@ -67,14 +88,22 @@ class ProgramConfigService {
private getDefaults(): ProgramConfigFile { private getDefaults(): ProgramConfigFile {
return { return {
programs: [ programs: [
{ id: 'p1', name: '龙腾四海', teamName: '市场部', order: 1 }, { id: 'p1', name: '龙腾四海', teamName: '市场部', performer: '待定', order: 1, remark: '赞美节目如琥珀般凝固了某个经典、美好、闪光的瞬间。' },
{ id: 'p2', name: '金马奔腾', teamName: '技术部', order: 2 }, { id: 'p2', name: '金马奔腾', teamName: '技术部', performer: '待定', order: 2, remark: '强调节目留下了值得回味的"声音"。' },
{ id: 'p3', name: '春风得意', teamName: '人力资源部', order: 3 }, { id: 'p3', name: '春风得意', teamName: '人力资源部', performer: '待定', order: 3, remark: '赞美节目引发了跨越时代的共鸣。' },
{ id: 'p4', name: '鸿运当头', teamName: '财务部', order: 4 }, { id: 'p4', name: '鸿运当头', teamName: '财务部', performer: '待定', order: 4, remark: '形容节目用声音和表演编织了一个时代的梦境。' },
{ id: 'p5', name: '马到成功', teamName: '运营部', order: 5 }, { id: 'p5', name: '马到成功', teamName: '运营部', performer: '待定', order: 5, remark: '既指复刻了过去的潮流,也指创造了今晚的潮流。' },
{ id: 'p6', name: '一马当先', teamName: '产品部', order: 6 }, { id: 'p6', name: '一马当先', teamName: '产品部', performer: '待定', order: 6, remark: '强调节目的独特韵味与精心打磨。' },
{ id: 'p7', name: '万马奔腾', teamName: '设计部', order: 7 }, { id: 'p7', name: '万马奔腾', teamName: '设计部', performer: '待定', order: 7, remark: '赞美节目与"复古70-80"主题高度契合。' },
{ id: 'p8', name: '龙马精神', teamName: '销售部', order: 8 }, ],
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: { settings: {
allowLateCatch: true, allowLateCatch: true,
@@ -90,6 +119,20 @@ class ProgramConfigService {
return this.config?.programs || this.getDefaults().programs; 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) * 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 * Update settings and save to file
*/ */

View File

@@ -1,7 +1,7 @@
import { redis } from '../config/redis'; import { redis } from '../config/redis';
import { config } from '../config'; import { config } from '../config';
import { logger } from '../utils/logger'; 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 type { VoteCategory } from '@gala/shared/types';
import { readFileSync } from 'fs'; import { readFileSync } from 'fs';
import { join, dirname } from 'path'; import { join, dirname } from 'path';
@@ -85,6 +85,7 @@ export class VoteService {
`${REDIS_KEYS.LEADERBOARD}:${category}`, `${REDIS_KEYS.LEADERBOARD}:${category}`,
REDIS_KEYS.SYNC_QUEUE, REDIS_KEYS.SYNC_QUEUE,
`${REDIS_KEYS.VOTE_LOCK}:${userId}:${category}`, `${REDIS_KEYS.VOTE_LOCK}:${userId}:${category}`,
VOTE_KEYS.userTickets(userId),
]; ];
const args = [ const args = [

View File

@@ -7,7 +7,7 @@ import { logger } from '../utils/logger';
import { voteService } from '../services/vote.service'; import { voteService } from '../services/vote.service';
import { votingEngine } from '../services/voting.engine'; import { votingEngine } from '../services/voting.engine';
import { adminService } from '../services/admin.service'; 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 { import type {
ServerToClientEvents, ServerToClientEvents,
ClientToServerEvents, ClientToServerEvents,
@@ -206,8 +206,9 @@ async function handleJoin(
await socket.join(SOCKET_ROOMS.ADMIN); 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 votedCategories = await voteService.getUserVotedCategories(userId);
const userTickets = await redis.hgetall(VOTE_KEYS.userTickets(userId));
logger.info({ socketId: socket.id, userId, userName, role }, 'User joined'); logger.info({ socketId: socket.id, userId, userName, role }, 'User joined');
@@ -221,6 +222,8 @@ async function handleJoin(
sessionId: socket.id, sessionId: socket.id,
serverTime: Date.now(), serverTime: Date.now(),
reconnected: false, reconnected: false,
votedCategories,
userTickets,
}, },
}); });
} catch (error) { } catch (error) {
@@ -298,18 +301,30 @@ async function handleVoteSubmit(
data.candidateId, data.candidateId,
socket.data.userName || '匿名用户', socket.data.userName || '匿名用户',
socket.data.department || '未知部门', socket.data.department || '未知部门',
category category,
{ revokedProgramId: result.revoked_program }
); );
// Broadcast vote update to all clients with stamp info // Broadcast vote update to all clients with stamp info
io.to(SOCKET_ROOMS.ALL).emit(SOCKET_EVENTS.VOTE_UPDATED as any, { io.to(SOCKET_ROOMS.ALL).emit(SOCKET_EVENTS.VOTE_UPDATED as any, {
candidateId: data.candidateId, candidateId: data.candidateId,
category: category, category: category,
totalVotes: result.new_count!, totalVotes: stampResult.totalVotes ?? 0,
programVotes: stampResult.programVotes ?? 0,
delta: 1, delta: 1,
stamp: stampResult.stamp, // Pass the stamp info for animation 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({ safeCallback({
success: true, success: true,
data: { data: {
@@ -346,7 +361,8 @@ async function handleVoteSubmit(
io.to(SOCKET_ROOMS.ALL).emit(SOCKET_EVENTS.VOTE_UPDATED as any, { io.to(SOCKET_ROOMS.ALL).emit(SOCKET_EVENTS.VOTE_UPDATED as any, {
candidateId: data.candidateId, candidateId: data.candidateId,
category: data.category, category: data.category,
totalVotes: result.new_count!, totalVotes: stampResult.totalVotes ?? 0,
programVotes: stampResult.programVotes ?? 0,
delta: 1, delta: 1,
stamp: stampResult.stamp, // Include stamp for big screen stamp: stampResult.stamp, // Include stamp for big screen
}); });
@@ -380,10 +396,12 @@ async function handleSyncRequest(socket: GalaSocket): Promise<void> {
try { try {
const votedCategories = await voteService.getUserVotedCategories(userId); const votedCategories = await voteService.getUserVotedCategories(userId);
const userTickets = await redis.hgetall(VOTE_KEYS.userTickets(userId));
socket.emit(SOCKET_EVENTS.SYNC_STATE as any, { socket.emit(SOCKET_EVENTS.SYNC_STATE as any, {
votes: {}, // TODO: Include current vote counts votes: {}, // TODO: Include current vote counts
userVotedCategories: votedCategories, userVotedCategories: votedCategories,
userTickets,
}); });
} catch (error) { } catch (error) {
logger.error({ socketId: socket.id, userId, error }, 'Sync request error'); logger.error({ socketId: socket.id, userId, error }, 'Sync request error');
@@ -649,4 +667,3 @@ function handleScanUnsubscribe(socket: GalaSocket, data: ScanSubscribePayload):
socket.leave(roomName); socket.leave(roomName);
logger.info({ socketId: socket.id, scanToken }, 'Socket unsubscribed from scan updates'); logger.info({ socketId: socket.id, scanToken }, 'Socket unsubscribed from scan updates');
} }

View File

@@ -38,16 +38,28 @@ export interface VotingState {
currentProgramId: string | null; // 当前投票节目 ID currentProgramId: string | null; // 当前投票节目 ID
currentProgramIndex: number; // 当前节目序号 (0-based index) currentProgramIndex: number; // 当前节目序号 (0-based index)
programs: VotingProgram[]; // 节目列表(已排序) programs: VotingProgram[]; // 节目列表(已排序)
awards: AwardConfig[]; // 奖项列表
allowLateCatch: boolean; // 补投票开关(默认 true allowLateCatch: boolean; // 补投票开关(默认 true
votingStartedAt?: number; // 当前节目投票开始时间(用于计时) votingStartedAt?: number; // 当前节目投票开始时间(用于计时)
} }
// 奖项配置
export interface AwardConfig {
id: string;
name: string;
icon: string;
remark?: string; // 奖项备注
order: number;
}
export type ProgramVotingStatus = 'pending' | 'voting' | 'completed'; export type ProgramVotingStatus = 'pending' | 'voting' | 'completed';
export interface VotingProgram { export interface VotingProgram {
id: string; id: string;
name: string; name: string;
teamName: string; teamName: string;
performer?: string; // 表演者
remark?: string; // 节目备注
order: number; // 初始顺序 order: number; // 初始顺序
status: ProgramVotingStatus; // 投票状态 status: ProgramVotingStatus; // 投票状态
votes: number; // 票数 votes: number; // 票数
@@ -148,14 +160,13 @@ export const PRIZE_CONFIG: PrizeConfig[] = [
// Default programs for voting // Default programs for voting
export const DEFAULT_PROGRAMS: VotingProgram[] = [ export const DEFAULT_PROGRAMS: VotingProgram[] = [
{ id: 'p1', name: '龙腾四海', teamName: '市场部', order: 1, status: 'pending', votes: 0, stamps: [] }, { id: 'p1', name: '龙腾四海', teamName: '市场部', performer: '待定', order: 1, remark: '赞美节目如琥珀般凝固了某个经典、美好、闪光的瞬间。', status: 'pending', votes: 0, stamps: [] },
{ id: 'p2', name: '金马奔腾', teamName: '技术部', order: 2, status: 'pending', votes: 0, stamps: [] }, { id: 'p2', name: '金马奔腾', teamName: '技术部', performer: '待定', order: 2, remark: '强调节目留下了值得回味的\'声音\',可以是歌声、朗诵声。', status: 'pending', votes: 0, stamps: [] },
{ id: 'p3', name: '春风得意', teamName: '人力资源部', order: 3, status: 'pending', votes: 0, stamps: [] }, { id: 'p3', name: '春风得意', teamName: '人力', performer: '待定', order: 3, remark: '赞美节目引发了跨越时代的共鸣。', status: 'pending', votes: 0, stamps: [] },
{ id: 'p4', name: '鸿运当头', teamName: '财务部', order: 4, status: 'pending', votes: 0, stamps: [] }, { id: 'p4', name: '鸿运当头', teamName: '财务部', performer: '待定', order: 4, remark: '形容节目用声音和表演编织了一个时代的梦境。', status: 'pending', votes: 0, stamps: [] },
{ id: 'p5', name: '马到成功', teamName: '运营部', order: 5, status: 'pending', votes: 0, stamps: [] }, { id: 'p5', name: '马到成功', teamName: '运营部', performer: '待定', order: 5, remark: '既指复刻了过去的潮流,也指创造了今晚的潮流。', status: 'pending', votes: 0, stamps: [] },
{ id: 'p6', name: '一马当先', teamName: '产品部', order: 6, status: 'pending', votes: 0, stamps: [] }, { id: 'p6', name: '一马当先', teamName: '产品部', performer: '待定', order: 6, remark: '强调节目的独特韵味与精心打磨。', status: 'pending', votes: 0, stamps: [] },
{ id: 'p7', name: '万马奔腾', teamName: '设计部', order: 7, status: 'pending', votes: 0, stamps: [] }, { id: 'p7', name: '万马奔腾', teamName: '设计部', performer: '待定', order: 7, remark: '赞美节目与时代精神同频共振。', status: 'pending', votes: 0, stamps: [] },
{ id: 'p8', name: '龙马精神', teamName: '销售部', order: 8, status: 'pending', votes: 0, stamps: [] },
]; ];
// ============================================================================ // ============================================================================
@@ -170,6 +181,7 @@ export const INITIAL_ADMIN_STATE: AdminState = {
currentProgramId: null, currentProgramId: null,
currentProgramIndex: -1, currentProgramIndex: -1,
programs: DEFAULT_PROGRAMS, programs: DEFAULT_PROGRAMS,
awards: [],
allowLateCatch: true, allowLateCatch: true,
}, },
lottery: { lottery: {

View File

@@ -19,6 +19,7 @@ export interface VoteUpdatePayload {
candidateId: string; candidateId: string;
category: VoteCategory; category: VoteCategory;
totalVotes: number; totalVotes: number;
programVotes?: number;
delta: number; delta: number;
} }
@@ -97,6 +98,7 @@ export interface ConnectionAckPayload {
export interface SyncStatePayload { export interface SyncStatePayload {
votes: Record<VoteCategory, Record<string, number>>; // category -> candidateId -> count votes: Record<VoteCategory, Record<string, number>>; // category -> candidateId -> count
userVotedCategories: VoteCategory[]; userVotedCategories: VoteCategory[];
userTickets?: Record<string, string | null>;
currentDraw?: { currentDraw?: {
isActive: boolean; isActive: boolean;
prizeLevel: PrizeLevel; prizeLevel: PrizeLevel;