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,6 +1,25 @@
|
|||||||
version: '3.8'
|
version: '3.8'
|
||||||
|
|
||||||
services:
|
services:
|
||||||
|
mysql:
|
||||||
|
image: mysql:8.0
|
||||||
|
container_name: gala-mysql
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD:-gala_root_pass}
|
||||||
|
- MYSQL_DATABASE=${MYSQL_DATABASE:-gala_db}
|
||||||
|
- MYSQL_USER=${MYSQL_USER:-gala_user}
|
||||||
|
- MYSQL_PASSWORD=${MYSQL_PASSWORD:-gala_pass}
|
||||||
|
volumes:
|
||||||
|
- mysql_data:/var/lib/mysql
|
||||||
|
networks:
|
||||||
|
- gala-network
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
redis:
|
redis:
|
||||||
image: redis:7-alpine
|
image: redis:7-alpine
|
||||||
container_name: gala-redis
|
container_name: gala-redis
|
||||||
@@ -22,8 +41,12 @@ services:
|
|||||||
- REDIS_HOST=redis
|
- REDIS_HOST=redis
|
||||||
- REDIS_PORT=6379
|
- REDIS_PORT=6379
|
||||||
- CORS_ORIGINS=${CORS_ORIGINS:-*}
|
- CORS_ORIGINS=${CORS_ORIGINS:-*}
|
||||||
|
- DATABASE_URL=mysql://${MYSQL_USER:-gala_user}:${MYSQL_PASSWORD:-gala_pass}@mysql:3306/${MYSQL_DATABASE:-gala_db}
|
||||||
depends_on:
|
depends_on:
|
||||||
- redis
|
mysql:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_started
|
||||||
networks:
|
networks:
|
||||||
- gala-network
|
- gala-network
|
||||||
|
|
||||||
@@ -49,6 +72,7 @@ services:
|
|||||||
- gala-network
|
- gala-network
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
|
mysql_data:
|
||||||
redis_data:
|
redis_data:
|
||||||
caddy_data:
|
caddy_data:
|
||||||
caddy_config:
|
caddy_config:
|
||||||
|
|||||||
@@ -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) {
|
if (data.userTickets) {
|
||||||
import('./voting').then(({ useVotingStore }) => {
|
import('./voting').then(({ useVotingStore }) => {
|
||||||
const votingStore = 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;
|
name: string;
|
||||||
icon: string;
|
icon: string;
|
||||||
order: number;
|
order: number;
|
||||||
|
remark?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 节目接口
|
// 节目接口
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ onMounted(async () => {
|
|||||||
<header class="page-header">
|
<header class="page-header">
|
||||||
<h1 class="page-title">节目投票</h1>
|
<h1 class="page-title">节目投票</h1>
|
||||||
<p class="page-subtitle">
|
<p class="page-subtitle">
|
||||||
已使用 {{ votingStore.usedTickets.length }}/7 枚印章
|
已使用 {{ votingStore.usedTicketCount }}/{{ votingStore.totalTicketCount }} 枚印章
|
||||||
</p>
|
</p>
|
||||||
<ConnectionStatus />
|
<ConnectionStatus />
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ RUN pnpm build
|
|||||||
WORKDIR /app/packages/server
|
WORKDIR /app/packages/server
|
||||||
RUN pnpm build
|
RUN pnpm build
|
||||||
|
|
||||||
|
# Generate Prisma Client in builder stage
|
||||||
|
RUN npx prisma generate
|
||||||
|
|
||||||
# Production stage
|
# Production stage
|
||||||
FROM node:20-alpine AS production
|
FROM node:20-alpine AS production
|
||||||
|
|
||||||
@@ -39,9 +42,15 @@ COPY --from=builder /app/pnpm-lock.yaml ./
|
|||||||
COPY --from=builder /app/packages/server/package.json ./packages/server/
|
COPY --from=builder /app/packages/server/package.json ./packages/server/
|
||||||
COPY --from=builder /app/packages/shared ./packages/shared
|
COPY --from=builder /app/packages/shared ./packages/shared
|
||||||
|
|
||||||
|
# Copy Prisma schema
|
||||||
|
COPY --from=builder /app/packages/server/prisma ./packages/server/prisma
|
||||||
|
|
||||||
# Install production dependencies only
|
# Install production dependencies only
|
||||||
RUN pnpm install --prod --frozen-lockfile
|
RUN pnpm install --prod --frozen-lockfile
|
||||||
|
|
||||||
|
# Copy generated Prisma Client from builder stage (pnpm workspace location)
|
||||||
|
COPY --from=builder /app/node_modules/.pnpm ./node_modules/.pnpm
|
||||||
|
|
||||||
# Copy built files
|
# Copy built files
|
||||||
COPY --from=builder /app/packages/server/dist ./packages/server/dist
|
COPY --from=builder /app/packages/server/dist ./packages/server/dist
|
||||||
COPY --from=builder /app/packages/server/src/lua ./packages/server/lua
|
COPY --from=builder /app/packages/server/src/lua ./packages/server/lua
|
||||||
|
|||||||
Reference in New Issue
Block a user