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:
2026-02-02 21:46:51 +08:00
parent e211bb2130
commit 296f6e09f8
6 changed files with 37 additions and 298 deletions

View File

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

View File

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

View File

@@ -9,6 +9,7 @@ export interface AwardConfig {
name: string;
icon: string;
order: number;
remark?: string;
}
// 节目接口

View File

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