Merge pull request 'fix/deployment-and-database-integration' (#2) from fix/deployment-and-database-integration into main

Reviewed-on: #2
This commit was merged in pull request #2.
This commit is contained in:
2026-02-03 13:45:10 +08:00
7 changed files with 66 additions and 326 deletions

View File

@@ -1,6 +1,25 @@
version: '3.8'
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:
image: redis:7-alpine
container_name: gala-redis
@@ -22,8 +41,12 @@ services:
- REDIS_HOST=redis
- REDIS_PORT=6379
- CORS_ORIGINS=${CORS_ORIGINS:-*}
- DATABASE_URL=mysql://${MYSQL_USER:-gala_user}:${MYSQL_PASSWORD:-gala_pass}@mysql:3306/${MYSQL_DATABASE:-gala_db}
depends_on:
- redis
mysql:
condition: service_healthy
redis:
condition: service_started
networks:
- gala-network
@@ -49,6 +72,7 @@ services:
- gala-network
volumes:
mysql_data:
redis_data:
caddy_data:
caddy_config:

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>

View File

@@ -23,6 +23,9 @@ RUN pnpm build
WORKDIR /app/packages/server
RUN pnpm build
# Generate Prisma Client in builder stage
RUN npx prisma generate
# Production stage
FROM node:20-alpine AS production
@@ -39,12 +42,19 @@ COPY --from=builder /app/pnpm-lock.yaml ./
COPY --from=builder /app/packages/server/package.json ./packages/server/
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
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 --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/config ./packages/config
ENV NODE_ENV=production
ENV PORT=3000

View File

@@ -2,59 +2,59 @@
"programs": [
{
"id": "p1",
"name": "龙腾四海",
"teamName": "市场部",
"performer": "张三、李四",
"name": "青苹果乐园",
"teamName": "",
"performer": "",
"order": 1,
"remark": "大型民族舞表演,融合了古典与现代元素,展现龙的精神。"
"remark": ""
},
{
"id": "p2",
"name": "金马奔腾",
"teamName": "技术部",
"performer": "王五、赵六",
"name": "五百年桑田沧海",
"teamName": "",
"performer": "",
"order": 2,
"remark": "动感的现代舞,充满力量与节奏感。"
"remark": ""
},
{
"id": "p3",
"name": "春风得意",
"teamName": "人力资源部",
"performer": "刘七、陈八",
"name": "我的中国心",
"teamName": "",
"performer": "",
"order": 3,
"remark": "温馨的情景剧,讲述了职场中的温暖瞬间。"
"remark": ""
},
{
"id": "p4",
"name": "鸿运当头",
"teamName": "财务部",
"performer": "周九、吴十",
"name": "萍聚",
"teamName": "",
"performer": "",
"order": 4,
"remark": "精彩的杂技表演,寓意新年鸿运连连。"
"remark": ""
},
{
"id": "p5",
"name": "马到成功",
"teamName": "运营部",
"performer": "郑十一、冯十二",
"name": "追光而行,共赴新程",
"teamName": "",
"performer": "",
"order": 5,
"remark": "热血沸腾的多人合唱,充满了前进的动力。"
"remark": ""
},
{
"id": "p6",
"name": "一马当先",
"teamName": "产品部",
"performer": "孙十三、杨十四",
"name": "粉红色的回忆",
"teamName": "",
"performer": "",
"order": 6,
"remark": "极具创意的光影秀,探索未来科技的可能。"
"remark": ""
},
{
"id": "p7",
"name": "万马奔腾",
"teamName": "设计部",
"performer": "何十五、林十六",
"name": "敬业狂想曲",
"teamName": "",
"performer": "",
"order": 7,
"remark": "大合唱,展现团队的凝聚力和向心力。"
"remark": ""
}
],
"awards": [