feat: redesign Big Screen voting view with philatelic postcard UI
- Add PostcardItem.vue component with Chinese postal aesthetics - Add PostcardGrid.vue container with 4x2 CSS Grid layout - Add Postmark.vue component for real-time vote stamp visualization - Update LiveVotingView.vue with cream paper theme (#FDFBF7) - Add Year of the Horse 2026 stamp image - Add responsive breakpoints for different screen sizes - Enhance admin service with program voting control - Add vote stamp accumulation for big screen display Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
BIN
packages/client-mobile/src/assets/images/stamp-horse-2026.png
Normal file
BIN
packages/client-mobile/src/assets/images/stamp-horse-2026.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
@@ -44,83 +44,40 @@ const currentDate = computed(() => {
|
||||
opacity: inkOpacity,
|
||||
}"
|
||||
>
|
||||
<!-- Circular Date Stamp Design -->
|
||||
<!-- Simplified SVG with award name on top and nickname on bottom -->
|
||||
<svg
|
||||
viewBox="0 0 120 120"
|
||||
viewBox="0 0 100 100"
|
||||
class="postmark-svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<!-- Outer Ring -->
|
||||
<circle
|
||||
cx="60"
|
||||
cy="60"
|
||||
r="54"
|
||||
fill="none"
|
||||
:stroke="inkColor"
|
||||
stroke-width="3"
|
||||
/>
|
||||
<!-- Double Ring Design (Mobile characteristic) -->
|
||||
<circle cx="50" cy="50" r="48" fill="none" :stroke="inkColor" stroke-width="2" />
|
||||
<circle cx="50" cy="50" r="42" fill="none" :stroke="inkColor" stroke-width="1" />
|
||||
|
||||
<!-- Inner Ring -->
|
||||
<circle
|
||||
cx="60"
|
||||
cy="60"
|
||||
r="46"
|
||||
fill="none"
|
||||
:stroke="inkColor"
|
||||
stroke-width="1.5"
|
||||
/>
|
||||
|
||||
<!-- Top Arc Text (Award Name) -->
|
||||
<!-- Text paths consistent with Big Screen -->
|
||||
<defs>
|
||||
<path
|
||||
id="topArc"
|
||||
d="M 15,60 A 45,45 0 0,1 105,60"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
id="bottomArc"
|
||||
d="M 105,60 A 45,45 0 0,1 15,60"
|
||||
fill="none"
|
||||
/>
|
||||
<path id="path-top" d="M 20,50 A 30,30 0 1,1 80,50" />
|
||||
<path id="path-bottom" d="M 80,50 A 30,30 0 0,1 20,50" />
|
||||
</defs>
|
||||
|
||||
<text
|
||||
:fill="inkColor"
|
||||
font-size="11"
|
||||
font-weight="bold"
|
||||
font-family="'Noto Serif SC', serif"
|
||||
letter-spacing="2"
|
||||
>
|
||||
<textPath href="#topArc" startOffset="50%" text-anchor="middle">
|
||||
<!-- Top Text: Award Name -->
|
||||
<text class="postmark-text top" :fill="inkColor">
|
||||
<textPath xlink:href="#path-top" startOffset="50%" text-anchor="middle">
|
||||
{{ awardName }}
|
||||
</textPath>
|
||||
</text>
|
||||
|
||||
<!-- Bottom Arc Text (Date & User) -->
|
||||
<text
|
||||
:fill="inkColor"
|
||||
font-size="8"
|
||||
font-family="'Courier New', monospace"
|
||||
>
|
||||
<textPath href="#bottomArc" startOffset="50%" text-anchor="middle">
|
||||
{{ currentDate }}{{ userName ? ` · ${userName}` : '' }}
|
||||
|
||||
<!-- Middle Text: Date -->
|
||||
<text x="50" y="50" class="postmark-text date" text-anchor="middle" dominant-baseline="central" :fill="inkColor">
|
||||
{{ currentDate }}
|
||||
</text>
|
||||
|
||||
<!-- Bottom Text: Nickname -->
|
||||
<text class="postmark-text bottom" :fill="inkColor">
|
||||
<textPath xlink:href="#path-bottom" startOffset="50%" text-anchor="middle">
|
||||
{{ userName || '访客' }}
|
||||
</textPath>
|
||||
</text>
|
||||
|
||||
<!-- Center Icon -->
|
||||
<text
|
||||
x="60"
|
||||
y="68"
|
||||
text-anchor="middle"
|
||||
font-size="28"
|
||||
class="center-icon"
|
||||
>
|
||||
{{ awardIcon }}
|
||||
</text>
|
||||
|
||||
<!-- Decorative Stars -->
|
||||
<text x="20" y="64" :fill="inkColor" font-size="8">★</text>
|
||||
<text x="95" y="64" :fill="inkColor" font-size="8">★</text>
|
||||
</svg>
|
||||
|
||||
<!-- Grunge Texture Overlay -->
|
||||
@@ -133,9 +90,9 @@ const currentDate = computed(() => {
|
||||
|
||||
.postmark {
|
||||
position: relative;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
// CRITICAL: Multiply blend mode for ink absorption effect
|
||||
width: 70px;
|
||||
height: 70px;
|
||||
// Multiply blend mode for realism
|
||||
mix-blend-mode: multiply;
|
||||
animation: stamp-reveal 0.3s ease-out forwards;
|
||||
transform-origin: center center;
|
||||
@@ -147,34 +104,24 @@ const currentDate = computed(() => {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.center-icon {
|
||||
// Emoji doesn't take fill color, but we can adjust opacity
|
||||
opacity: 0.9;
|
||||
.postmark-text {
|
||||
font-family: 'Kaiti', 'STKaiti', serif;
|
||||
font-weight: bold;
|
||||
|
||||
&.top { font-size: 11px; }
|
||||
&.date { font-size: 10px; letter-spacing: 0.5px; }
|
||||
&.bottom { font-size: 10px; }
|
||||
}
|
||||
|
||||
// Grunge texture for realistic ink imperfection
|
||||
.grunge-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 100 100' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)' opacity='0.12'/%3E%3C/svg%3E");
|
||||
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 100 100' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)' opacity='0.08'/%3E%3C/svg%3E");
|
||||
mix-blend-mode: overlay;
|
||||
pointer-events: none;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
// Color variants
|
||||
.postmark--red {
|
||||
.postmark-svg {
|
||||
filter: drop-shadow(0 0 1px rgba(194, 31, 48, 0.3));
|
||||
}
|
||||
}
|
||||
|
||||
.postmark--gold {
|
||||
.postmark-svg {
|
||||
filter: drop-shadow(0 0 1px rgba(212, 168, 75, 0.3));
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes stamp-reveal {
|
||||
0% {
|
||||
opacity: 0;
|
||||
|
||||
@@ -3,7 +3,9 @@ import { computed, ref } from 'vue';
|
||||
import { useVotingStore, TICKET_INFO } from '../stores/voting';
|
||||
import { useConnectionStore } from '../stores/connection';
|
||||
import Postmark from './Postmark.vue';
|
||||
import stampImage from '../assets/images/stamp-horse-2026.png';
|
||||
import type { TicketType } from '@gala/shared/constants';
|
||||
import type { VoteStamp } from '@gala/shared/types';
|
||||
|
||||
interface Props {
|
||||
programId: string;
|
||||
@@ -11,10 +13,14 @@ interface Props {
|
||||
teamName?: string;
|
||||
coverImage?: string;
|
||||
index?: number; // For stagger animation
|
||||
status?: 'pending' | 'voting' | 'completed';
|
||||
isCurrent?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
index: 0,
|
||||
status: 'pending',
|
||||
isCurrent: false,
|
||||
});
|
||||
|
||||
const votingStore = useVotingStore();
|
||||
@@ -33,9 +39,23 @@ const stampInfo = computed(() => {
|
||||
return TICKET_INFO[stampedWith.value];
|
||||
});
|
||||
|
||||
// Can this card receive a stamp
|
||||
// Can this card receive a stamp (requires voting permission check)
|
||||
const canVote = computed(() => {
|
||||
const check = votingStore.canVoteForProgram(props.programId);
|
||||
return check.allowed;
|
||||
});
|
||||
|
||||
const canReceiveStamp = computed(() => {
|
||||
return votingStore.isStampSelected && !hasStamp.value;
|
||||
return votingStore.isStampSelected && !hasStamp.value && canVote.value;
|
||||
});
|
||||
|
||||
// Get voting status message
|
||||
const votingStatusLabel = computed(() => {
|
||||
switch (props.status) {
|
||||
case 'voting': return '投票中';
|
||||
case 'completed': return '已结束';
|
||||
default: return '待投票';
|
||||
}
|
||||
});
|
||||
|
||||
// Stagger delay for entrance animation
|
||||
@@ -84,10 +104,16 @@ function delay(ms: number): Promise<void> {
|
||||
'has-stamp': hasStamp,
|
||||
'can-stamp': canReceiveStamp,
|
||||
'is-stamping': isStamping,
|
||||
'is-current': isCurrent,
|
||||
'is-disabled': !canVote && !hasStamp,
|
||||
}"
|
||||
:style="{ '--entrance-delay': entranceDelay }"
|
||||
@click="handleCardClick"
|
||||
>
|
||||
<!-- Status Badge -->
|
||||
<div v-if="status !== 'pending'" class="status-badge" :class="status">
|
||||
{{ votingStatusLabel }}
|
||||
</div>
|
||||
<!-- Paper Texture Background -->
|
||||
<div class="paper-texture">
|
||||
<!-- Left: Cover Image (Picture Side) -->
|
||||
@@ -128,6 +154,10 @@ function delay(ms: number): Promise<void> {
|
||||
|
||||
<!-- Stamp Area -->
|
||||
<div class="stamp-zone">
|
||||
<!-- Real stamp image (always visible) -->
|
||||
<img :src="stampImage" alt="邮票" class="stamp-image" />
|
||||
|
||||
<!-- Postmark overlay on bottom-left of stamp -->
|
||||
<Postmark
|
||||
v-if="hasStamp && stampInfo"
|
||||
:award-name="stampInfo.name"
|
||||
@@ -136,10 +166,6 @@ function delay(ms: number): Promise<void> {
|
||||
color="red"
|
||||
class="applied-stamp"
|
||||
/>
|
||||
<div v-else class="stamp-placeholder">
|
||||
<span class="stamp-label">贴票处</span>
|
||||
<span class="placeholder-label">PLACE STAMP HERE</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -177,7 +203,7 @@ $ink-charcoal: #333;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease, opacity 0.3s ease;
|
||||
|
||||
// Entrance animation
|
||||
animation: postcard-enter 0.5s ease-out backwards;
|
||||
@@ -197,6 +223,43 @@ $ink-charcoal: #333;
|
||||
background-color: color.adjust($paper-cream, $lightness: -2%);
|
||||
}
|
||||
}
|
||||
|
||||
&.is-current {
|
||||
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 {
|
||||
@@ -334,13 +397,24 @@ $ink-charcoal: #333;
|
||||
width: 70px;
|
||||
height: 70px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.stamp-image {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
object-fit: contain;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.applied-stamp {
|
||||
position: absolute;
|
||||
bottom: -5px;
|
||||
left: -10px;
|
||||
mix-blend-mode: multiply;
|
||||
transform: rotate(5deg);
|
||||
transform: rotate(-8deg);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.stamp-placeholder {
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
ConnectionAckPayload,
|
||||
VoteCategory,
|
||||
SyncStatePayload,
|
||||
AdminState,
|
||||
} from '@gala/shared/types';
|
||||
import { SOCKET_EVENTS } from '@gala/shared/constants';
|
||||
import { CONFIG } from '@gala/shared/constants';
|
||||
@@ -24,6 +25,7 @@ export const useConnectionStore = defineStore('connection', () => {
|
||||
const reconnectAttempts = ref(0);
|
||||
const userId = ref<string | null>(null);
|
||||
const userName = ref<string | null>(null);
|
||||
const department = ref<string | null>(null);
|
||||
const votedCategories = ref<VoteCategory[]>([]);
|
||||
|
||||
// Computed
|
||||
@@ -116,6 +118,29 @@ export const useConnectionStore = defineStore('connection', () => {
|
||||
votedCategories.value = data.userVotedCategories;
|
||||
}
|
||||
});
|
||||
// Admin state sync - update voting state
|
||||
socketInstance.on(SOCKET_EVENTS.ADMIN_STATE_SYNC as any, (state: AdminState) => {
|
||||
console.log('[Socket] Admin state sync received');
|
||||
// Dynamically import voting store to avoid circular dependency
|
||||
import('./voting').then(({ useVotingStore }) => {
|
||||
const votingStore = useVotingStore();
|
||||
votingStore.syncVotingState({
|
||||
votingOpen: state.voting.subPhase === 'OPEN',
|
||||
votingPaused: state.voting.subPhase === 'PAUSED',
|
||||
programs: state.voting.programs,
|
||||
currentProgramId: state.voting.currentProgramId,
|
||||
allowLateCatch: state.voting.allowLateCatch,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Remote vote update (from other users)
|
||||
socketInstance.on(SOCKET_EVENTS.VOTE_UPDATED as any, (data: any) => {
|
||||
import('./voting').then(({ useVotingStore }) => {
|
||||
const votingStore = useVotingStore();
|
||||
votingStore.handleVoteUpdate(data);
|
||||
});
|
||||
});
|
||||
|
||||
socket.value = socketInstance as GalaSocket;
|
||||
}
|
||||
@@ -131,6 +156,7 @@ export const useConnectionStore = defineStore('connection', () => {
|
||||
{
|
||||
userId: userId.value,
|
||||
userName: userName.value || 'Guest',
|
||||
department: department.value || '未知部门',
|
||||
role: 'user',
|
||||
},
|
||||
(response: any) => {
|
||||
@@ -193,9 +219,10 @@ export const useConnectionStore = defineStore('connection', () => {
|
||||
/**
|
||||
* Set user info
|
||||
*/
|
||||
function setUser(id: string, name: string) {
|
||||
function setUser(id: string, name: string, dept: string) {
|
||||
userId.value = id;
|
||||
userName.value = name;
|
||||
department.value = dept;
|
||||
|
||||
// Rejoin if already connected
|
||||
if (socket.value?.connected) {
|
||||
@@ -237,6 +264,7 @@ export const useConnectionStore = defineStore('connection', () => {
|
||||
reconnectAttempts,
|
||||
userId,
|
||||
userName,
|
||||
department,
|
||||
votedCategories,
|
||||
|
||||
// Computed
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ref, computed } from 'vue';
|
||||
import { useConnectionStore } from './connection';
|
||||
import { TICKET_TYPES, type TicketType } from '@gala/shared/constants';
|
||||
import { showToast } from 'vant';
|
||||
import type { VoteStamp } from '@gala/shared/types';
|
||||
|
||||
// Ticket display info
|
||||
export const TICKET_INFO: Record<TicketType, { name: string; icon: string }> = {
|
||||
@@ -21,6 +22,15 @@ interface PendingVote {
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
interface VotingProgram {
|
||||
id: string;
|
||||
name: string;
|
||||
teamName: string;
|
||||
order: number;
|
||||
status: 'pending' | 'voting' | 'completed';
|
||||
votes: number;
|
||||
}
|
||||
|
||||
export const useVotingStore = defineStore('voting', () => {
|
||||
const connectionStore = useConnectionStore();
|
||||
|
||||
@@ -35,6 +45,13 @@ export const useVotingStore = defineStore('voting', () => {
|
||||
potential: null,
|
||||
});
|
||||
|
||||
// Server-synced voting state
|
||||
const votingOpen = ref(false);
|
||||
const votingPaused = ref(false);
|
||||
const programs = ref<VotingProgram[]>([]);
|
||||
const currentProgramId = ref<string | null>(null);
|
||||
const allowLateCatch = ref(true);
|
||||
|
||||
// Currently selected stamp in dock
|
||||
const selectedStamp = ref<TicketType | null>(null);
|
||||
|
||||
@@ -211,6 +228,63 @@ export const useVotingStore = defineStore('voting', () => {
|
||||
tickets.value = { ...serverTickets };
|
||||
}
|
||||
|
||||
// Sync voting state from AdminState
|
||||
function syncVotingState(state: {
|
||||
votingOpen?: boolean;
|
||||
votingPaused?: boolean;
|
||||
programs?: VotingProgram[];
|
||||
currentProgramId?: string | null;
|
||||
allowLateCatch?: boolean;
|
||||
}) {
|
||||
if (state.votingOpen !== undefined) votingOpen.value = state.votingOpen;
|
||||
if (state.votingPaused !== undefined) votingPaused.value = state.votingPaused;
|
||||
if (state.programs !== undefined) programs.value = state.programs;
|
||||
if (state.currentProgramId !== undefined) currentProgramId.value = state.currentProgramId;
|
||||
if (state.allowLateCatch !== undefined) allowLateCatch.value = state.allowLateCatch;
|
||||
}
|
||||
|
||||
// Check if voting is allowed for a specific program
|
||||
// In unified voting mode, all programs can be voted when voting is open
|
||||
function canVoteForProgram(programId: string): { allowed: boolean; reason?: string } {
|
||||
// Check if voting is open
|
||||
if (!votingOpen.value) {
|
||||
return { allowed: false, reason: '投票尚未开始' };
|
||||
}
|
||||
if (votingPaused.value) {
|
||||
return { allowed: false, reason: '投票已暂停' };
|
||||
}
|
||||
|
||||
// In unified voting mode, all programs are votable when voting is open
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
// Handle real-time vote updates
|
||||
function handleVoteUpdate(data: { candidateId: string; newCount: number; totalVotes: number }) {
|
||||
const program = programs.value.find(p => p.id === data.candidateId);
|
||||
if (program) {
|
||||
program.votes = data.newCount;
|
||||
}
|
||||
// Update global total votes if provided
|
||||
if (data.totalVotes !== undefined) {
|
||||
// We don't have a totalVotes ref in the store yet, but we could add it or just ignore
|
||||
}
|
||||
}
|
||||
|
||||
// Get current voting program
|
||||
const currentProgram = computed(() => {
|
||||
if (!currentProgramId.value) return null;
|
||||
return programs.value.find(p => p.id === currentProgramId.value) || null;
|
||||
});
|
||||
|
||||
// Get votable programs (voting or completed with late catch)
|
||||
const votablePrograms = computed(() => {
|
||||
return programs.value.filter(p => {
|
||||
if (p.status === 'voting') return true;
|
||||
if (p.status === 'completed' && allowLateCatch.value) return true;
|
||||
return false;
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
tickets,
|
||||
selectedStamp,
|
||||
@@ -225,5 +299,17 @@ export const useVotingStore = defineStore('voting', () => {
|
||||
castVote,
|
||||
revokeVote,
|
||||
syncFromServer,
|
||||
// New exports
|
||||
votingOpen,
|
||||
votingPaused,
|
||||
programs,
|
||||
currentProgramId,
|
||||
allowLateCatch,
|
||||
currentProgram,
|
||||
votablePrograms,
|
||||
syncVotingState,
|
||||
canVoteForProgram,
|
||||
handleVoteUpdate,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ const router = useRouter();
|
||||
const connectionStore = useConnectionStore();
|
||||
|
||||
const userName = ref('');
|
||||
const userDept = ref('技术部');
|
||||
const isLoading = ref(false);
|
||||
|
||||
async function handleEnter() {
|
||||
@@ -21,7 +22,7 @@ async function handleEnter() {
|
||||
// Generate a simple user ID (in production, this would come from auth)
|
||||
const odrawId = `user_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
connectionStore.setUser(odrawId, userName.value.trim());
|
||||
connectionStore.setUser(odrawId, userName.value.trim(), userDept.value);
|
||||
|
||||
// Wait for connection
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
@@ -61,6 +62,17 @@ async function handleEnter() {
|
||||
@keyup.enter="handleEnter"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="input-wrapper guochao-border">
|
||||
<van-field
|
||||
v-model="userDept"
|
||||
placeholder="请输入您的部门"
|
||||
:border="false"
|
||||
clearable
|
||||
maxlength="20"
|
||||
@keyup.enter="handleEnter"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<van-button
|
||||
class="enter-btn"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { computed, onMounted } from 'vue';
|
||||
import { useVotingStore } from '../stores/voting';
|
||||
import { useConnectionStore } from '../stores/connection';
|
||||
import VotingDock from '../components/VotingDock.vue';
|
||||
@@ -8,19 +8,29 @@ import ProgramCard from '../components/ProgramCard.vue';
|
||||
const votingStore = useVotingStore();
|
||||
const connectionStore = useConnectionStore();
|
||||
|
||||
// 节目列表(从后端获取,这里先用 mock 数据)
|
||||
const programs = ref([
|
||||
{ id: 'p1', name: '龙腾四海', team: '市场部', coverImage: '' },
|
||||
{ id: 'p2', name: '金马奔腾', team: '技术部', coverImage: '' },
|
||||
{ id: 'p3', name: '春风得意', team: '人力资源部', coverImage: '' },
|
||||
{ id: 'p4', name: '鸿运当头', team: '财务部', coverImage: '' },
|
||||
{ id: 'p5', name: '马到成功', team: '运营部', coverImage: '' },
|
||||
{ id: 'p6', name: '一马当先', team: '产品部', coverImage: '' },
|
||||
{ id: 'p7', name: '万马奔腾', team: '设计部', coverImage: '' },
|
||||
{ id: 'p8', name: '龙马精神', team: '销售部', coverImage: '' },
|
||||
]);
|
||||
// Use server-synced programs from voting store
|
||||
const programs = computed(() => {
|
||||
// If no programs from server, show default list
|
||||
if (votingStore.programs.length === 0) {
|
||||
return [
|
||||
{ id: 'p1', name: '龙腾四海', teamName: '市场部', order: 1, status: 'pending' as const, votes: 0 },
|
||||
{ id: 'p2', name: '金马奔腾', teamName: '技术部', order: 2, status: 'pending' as const, votes: 0 },
|
||||
{ id: 'p3', name: '春风得意', teamName: '人力资源部', order: 3, status: 'pending' as const, votes: 0 },
|
||||
{ id: 'p4', name: '鸿运当头', teamName: '财务部', order: 4, status: 'pending' as const, votes: 0 },
|
||||
{ id: 'p5', name: '马到成功', teamName: '运营部', order: 5, status: 'pending' as const, votes: 0 },
|
||||
{ id: 'p6', name: '一马当先', teamName: '产品部', order: 6, status: 'pending' as const, votes: 0 },
|
||||
{ id: 'p7', name: '万马奔腾', teamName: '设计部', order: 7, status: 'pending' as const, votes: 0 },
|
||||
{ id: 'p8', name: '龙马精神', teamName: '销售部', order: 8, status: 'pending' as const, votes: 0 },
|
||||
];
|
||||
}
|
||||
return votingStore.programs;
|
||||
});
|
||||
|
||||
const isLoading = ref(false);
|
||||
// Voting status message
|
||||
const votingStatusMessage = computed(() => {
|
||||
if (!votingStore.votingOpen) return '投票尚未开始';
|
||||
return '投票进行中';
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
if (!connectionStore.isConnected) {
|
||||
@@ -44,15 +54,23 @@ onMounted(() => {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Voting Status Bar -->
|
||||
<div class="voting-status-bar" :class="{ active: votingStore.votingOpen }">
|
||||
<span class="status-dot" :class="{ pulsing: votingStore.votingOpen }"></span>
|
||||
<span class="status-text">{{ votingStatusMessage }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Program List -->
|
||||
<main class="program-list">
|
||||
<ProgramCard
|
||||
v-for="program in programs"
|
||||
v-for="(program, index) in programs"
|
||||
:key="program.id"
|
||||
:program-id="program.id"
|
||||
:program-name="program.name"
|
||||
:team-name="program.team"
|
||||
:cover-image="program.coverImage"
|
||||
:team-name="program.teamName"
|
||||
:index="index"
|
||||
:status="program.status"
|
||||
:is-current="program.id === votingStore.currentProgramId"
|
||||
/>
|
||||
</main>
|
||||
|
||||
@@ -132,4 +150,51 @@ onMounted(() => {
|
||||
flex-direction: column;
|
||||
gap: $spacing-lg;
|
||||
}
|
||||
|
||||
// Voting Status Bar
|
||||
.voting-status-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: $spacing-sm $spacing-lg;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
font-size: $font-size-sm;
|
||||
color: $color-text-secondary;
|
||||
|
||||
&.active {
|
||||
background: rgba($color-gold, 0.1);
|
||||
border-color: rgba($color-gold, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #666;
|
||||
|
||||
&.pulsing {
|
||||
background: #22c55e;
|
||||
animation: pulse-dot 1.5s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse-dot {
|
||||
0%, 100% { opacity: 1; box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.4); }
|
||||
50% { opacity: 0.8; box-shadow: 0 0 0 4px rgba(34, 197, 94, 0); }
|
||||
}
|
||||
|
||||
.status-text {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.late-catch-hint {
|
||||
font-size: 10px;
|
||||
color: $color-gold;
|
||||
padding: 2px 8px;
|
||||
background: rgba($color-gold, 0.15);
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user