fix: add MySQL database and fix deployment issues
## Changes ### Database Integration - Add MySQL 8.0 service to docker-compose.yml - Configure DATABASE_URL environment variable for server - Add health check for MySQL service - Create mysql_data volume for data persistence ### Dockerfile Improvements - Generate Prisma Client in builder stage - Copy Prisma Client from correct pnpm workspace location - Ensure Prisma Client is available in production container ### Client-Mobile Fixes - Remove deprecated StampDock.vue component - Fix voting store API usage in VotingPage.vue - Add type assertion for userTickets in connection.ts - Add remark property to AwardConfig interface in voting.ts ## Testing - All containers start successfully - Database connection established - Redis connection working - 94 participants restored from Redis - Vote data synced (20 votes) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,295 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useVotingStore, TICKET_INFO } from '../stores/voting';
|
||||
import { TICKET_TYPES, type TicketType } from '@gala/shared/constants';
|
||||
|
||||
const votingStore = useVotingStore();
|
||||
|
||||
const stamps = computed(() => {
|
||||
return TICKET_TYPES.map((type) => ({
|
||||
type,
|
||||
...TICKET_INFO[type],
|
||||
isUsed: votingStore.tickets[type] !== null,
|
||||
isSelected: votingStore.selectedStamp === type,
|
||||
}));
|
||||
});
|
||||
|
||||
function handleStampClick(type: TicketType) {
|
||||
if (votingStore.tickets[type] !== null) return; // Already used
|
||||
|
||||
if (votingStore.selectedStamp === type) {
|
||||
votingStore.deselectStamp();
|
||||
} else {
|
||||
votingStore.selectStamp(type);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="stamp-dock">
|
||||
<!-- Wooden Tray -->
|
||||
<div class="wooden-tray">
|
||||
<!-- Tray Edge (Top) -->
|
||||
<div class="tray-edge tray-edge-top"></div>
|
||||
|
||||
<!-- Stamp Handles -->
|
||||
<div class="stamp-tray">
|
||||
<div
|
||||
v-for="stamp in stamps"
|
||||
:key="stamp.type"
|
||||
class="stamp-handle"
|
||||
:class="{
|
||||
'is-used': stamp.isUsed,
|
||||
'is-selected': stamp.isSelected,
|
||||
}"
|
||||
@click="handleStampClick(stamp.type)"
|
||||
>
|
||||
<!-- Handle Body (Wood) -->
|
||||
<div class="handle-body">
|
||||
<div class="handle-grip"></div>
|
||||
</div>
|
||||
|
||||
<!-- Rubber Base (Shows ink design) -->
|
||||
<div class="rubber-base">
|
||||
<span class="rubber-icon">{{ stamp.icon }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Label -->
|
||||
<span class="stamp-label">{{ stamp.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tray Edge (Bottom) -->
|
||||
<div class="tray-edge tray-edge-bottom"></div>
|
||||
</div>
|
||||
|
||||
<!-- Hint Text -->
|
||||
<div v-if="votingStore.isStampSelected" class="dock-hint">
|
||||
<span class="hint-icon">👆</span>
|
||||
<span class="hint-text">点击节目卡片盖章</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@use 'sass:color';
|
||||
@use '../assets/styles/variables.scss' as *;
|
||||
|
||||
// Wood colors
|
||||
$wood-light: #d4a574;
|
||||
$wood-medium: #b8864e;
|
||||
$wood-dark: #8b5a2b;
|
||||
$wood-grain: #a0693a;
|
||||
|
||||
.stamp-dock {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: $z-index-fixed;
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
.wooden-tray {
|
||||
background: linear-gradient(180deg, $wood-light 0%, $wood-medium 50%, $wood-dark 100%);
|
||||
border-top: 3px solid $wood-dark;
|
||||
position: relative;
|
||||
|
||||
// Wood grain texture
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image: repeating-linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
transparent 2px,
|
||||
rgba($wood-grain, 0.1) 2px,
|
||||
rgba($wood-grain, 0.1) 4px
|
||||
);
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.tray-edge {
|
||||
height: 8px;
|
||||
background: linear-gradient(90deg, $wood-dark, $wood-medium, $wood-dark);
|
||||
|
||||
&.tray-edge-top {
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
&.tray-edge-bottom {
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.stamp-tray {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
padding: $spacing-sm $spacing-xs;
|
||||
gap: 4px;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.stamp-handle {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s ease-out;
|
||||
|
||||
&:active:not(.is-used) {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
&.is-used {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
|
||||
.handle-body {
|
||||
filter: grayscale(0.5);
|
||||
}
|
||||
|
||||
.rubber-base {
|
||||
filter: grayscale(1);
|
||||
}
|
||||
}
|
||||
|
||||
&.is-selected {
|
||||
transform: translateY(-10px);
|
||||
|
||||
.handle-body {
|
||||
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.rubber-base {
|
||||
box-shadow: 0 4px 12px rgba($color-primary, 0.5);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.handle-body {
|
||||
width: 40px;
|
||||
height: 28px;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
#654321 0%,
|
||||
#8b4513 30%,
|
||||
#a0522d 50%,
|
||||
#8b4513 70%,
|
||||
#654321 100%
|
||||
);
|
||||
border-radius: 6px 6px 2px 2px;
|
||||
position: relative;
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
|
||||
transition: box-shadow 0.15s, transform 0.15s;
|
||||
}
|
||||
|
||||
.handle-grip {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 24px;
|
||||
height: 4px;
|
||||
background: linear-gradient(90deg, #5a3a1a, #7a5a3a, #5a3a1a);
|
||||
border-radius: 2px;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 20px;
|
||||
height: 3px;
|
||||
background: linear-gradient(90deg, #5a3a1a, #7a5a3a, #5a3a1a);
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.rubber-base {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: $color-primary;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
|
||||
transition: box-shadow 0.15s;
|
||||
|
||||
// Rubber texture
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: radial-gradient(
|
||||
circle at 30% 30%,
|
||||
rgba(255, 255, 255, 0.1) 0%,
|
||||
transparent 50%
|
||||
);
|
||||
border-radius: 4px;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.rubber-icon {
|
||||
font-size: 20px;
|
||||
filter: brightness(0) invert(1); // White icon (reverse of stamp)
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.stamp-label {
|
||||
font-size: 10px;
|
||||
color: #f5f0e6;
|
||||
font-family: 'Courier New', monospace;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
|
||||
white-space: nowrap;
|
||||
max-width: 48px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.dock-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: $spacing-xs;
|
||||
padding: $spacing-xs;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
animation: hint-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.hint-icon {
|
||||
font-size: 14px;
|
||||
animation: bounce 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.hint-text {
|
||||
font-size: $font-size-sm;
|
||||
color: $color-gold;
|
||||
font-family: 'Courier New', monospace;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
@keyframes hint-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.7; }
|
||||
}
|
||||
|
||||
@keyframes bounce {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-4px); }
|
||||
}
|
||||
</style>
|
||||
@@ -150,7 +150,7 @@ export const useConnectionStore = defineStore('connection', () => {
|
||||
if (data.userTickets) {
|
||||
import('./voting').then(({ useVotingStore }) => {
|
||||
const votingStore = useVotingStore();
|
||||
votingStore.syncFromServer(data.userTickets);
|
||||
votingStore.syncFromServer(data.userTickets as Record<string, string | null>);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface AwardConfig {
|
||||
name: string;
|
||||
icon: string;
|
||||
order: number;
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
// 节目接口
|
||||
|
||||
@@ -37,7 +37,7 @@ onMounted(async () => {
|
||||
<header class="page-header">
|
||||
<h1 class="page-title">节目投票</h1>
|
||||
<p class="page-subtitle">
|
||||
已使用 {{ votingStore.usedTickets.length }}/7 枚印章
|
||||
已使用 {{ votingStore.usedTicketCount }}/{{ votingStore.totalTicketCount }} 枚印章
|
||||
</p>
|
||||
<ConnectionStatus />
|
||||
</header>
|
||||
|
||||
Reference in New Issue
Block a user