feat: initialize Annual Gala Interactive System monorepo
- Set up pnpm workspace with 4 packages: shared, server, client-mobile, client-screen - Implement Redis atomic voting with Lua scripts (HINCRBY + distributed lock) - Add optimistic UI with IndexedDB queue for offline resilience - Configure Socket.io with auto-reconnection (infinite retries) - Separate mobile (Vant) and big screen (Pixi.js) dependencies Tech stack: - Frontend Mobile: Vue 3 + Vant + Socket.io-client - Frontend Screen: Vue 3 + Pixi.js + GSAP - Backend: Express + Socket.io + Redis + Prisma/MySQL Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
17
packages/client-screen/index.html
Normal file
17
packages/client-screen/index.html
Normal file
@@ -0,0 +1,17 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>年会大屏 - 抽奖互动</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body { width: 100%; height: 100%; overflow: hidden; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
31
packages/client-screen/package.json
Normal file
31
packages/client-screen/package.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "@gala/client-screen",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "vue-tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@gala/shared": "workspace:*",
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^4.5.0",
|
||||
"pinia": "^2.3.0",
|
||||
"pixi.js": "^8.6.6",
|
||||
"@pixi/particle-emitter": "^5.0.8",
|
||||
"gsap": "^3.12.7",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"howler": "^2.2.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"vite": "^6.0.7",
|
||||
"sass": "^1.83.1",
|
||||
"typescript": "^5.7.3",
|
||||
"vue-tsc": "^2.2.0",
|
||||
"@types/howler": "^2.2.12"
|
||||
}
|
||||
}
|
||||
111
packages/client-screen/src/App.vue
Normal file
111
packages/client-screen/src/App.vue
Normal file
@@ -0,0 +1,111 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref } from 'vue';
|
||||
import { useDisplayStore } from './stores/display';
|
||||
|
||||
const displayStore = useDisplayStore();
|
||||
const isFullscreen = ref(false);
|
||||
|
||||
function toggleFullscreen() {
|
||||
if (!document.fullscreenElement) {
|
||||
document.documentElement.requestFullscreen();
|
||||
isFullscreen.value = true;
|
||||
} else {
|
||||
document.exitFullscreen();
|
||||
isFullscreen.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
displayStore.connect();
|
||||
|
||||
// Listen for fullscreen changes
|
||||
document.addEventListener('fullscreenchange', () => {
|
||||
isFullscreen.value = !!document.fullscreenElement;
|
||||
});
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
displayStore.disconnect();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="screen-app" @dblclick="toggleFullscreen">
|
||||
<router-view />
|
||||
|
||||
<!-- Fullscreen hint -->
|
||||
<div v-if="!isFullscreen" class="fullscreen-hint">
|
||||
双击进入全屏模式
|
||||
</div>
|
||||
|
||||
<!-- Connection status -->
|
||||
<div class="connection-indicator" :class="displayStore.connectionStatus">
|
||||
<span class="dot"></span>
|
||||
<span class="text">{{ displayStore.connectionStatus === 'connected' ? '已连接' : '连接中...' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.screen-app {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
background: #0a0a0a;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.fullscreen-hint {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
padding: 8px 16px;
|
||||
border-radius: 20px;
|
||||
font-size: 14px;
|
||||
pointer-events: none;
|
||||
animation: fadeInOut 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes fadeInOut {
|
||||
0%, 100% { opacity: 0.3; }
|
||||
50% { opacity: 0.8; }
|
||||
}
|
||||
|
||||
.connection-indicator {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
padding: 8px 16px;
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
color: white;
|
||||
|
||||
.dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #ff4d4f;
|
||||
}
|
||||
|
||||
&.connected .dot {
|
||||
background: #52c41a;
|
||||
}
|
||||
|
||||
&.connecting .dot {
|
||||
background: #faad14;
|
||||
animation: pulse 1s infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
</style>
|
||||
86
packages/client-screen/src/assets/styles/global.scss
Normal file
86
packages/client-screen/src/assets/styles/global.scss
Normal file
@@ -0,0 +1,86 @@
|
||||
@import './variables.scss';
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: $color-bg-dark;
|
||||
font-family: 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
color: $color-text-light;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
// Gold gradient text
|
||||
.gold-text {
|
||||
background: linear-gradient(135deg, $color-gold-light 0%, $color-gold 50%, $color-gold-dark 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
// Red glow text
|
||||
.glow-text {
|
||||
text-shadow: $glow-red;
|
||||
}
|
||||
|
||||
// Gold glow text
|
||||
.gold-glow-text {
|
||||
text-shadow: $glow-gold;
|
||||
}
|
||||
|
||||
// Decorative border
|
||||
.guochao-frame {
|
||||
border: 2px solid $color-gold;
|
||||
position: relative;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid $color-gold;
|
||||
}
|
||||
|
||||
&::before {
|
||||
top: -10px;
|
||||
left: -10px;
|
||||
border-right: none;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&::after {
|
||||
bottom: -10px;
|
||||
right: -10px;
|
||||
border-left: none;
|
||||
border-top: none;
|
||||
}
|
||||
}
|
||||
|
||||
// Animation keyframes
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-10px); }
|
||||
}
|
||||
|
||||
@keyframes glow-pulse {
|
||||
0%, 100% { opacity: 1; filter: brightness(1); }
|
||||
50% { opacity: 0.8; filter: brightness(1.2); }
|
||||
}
|
||||
|
||||
@keyframes rotate {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@keyframes scale-pulse {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.05); }
|
||||
}
|
||||
27
packages/client-screen/src/assets/styles/variables.scss
Normal file
27
packages/client-screen/src/assets/styles/variables.scss
Normal file
@@ -0,0 +1,27 @@
|
||||
// Guochao Red & Gold Theme for Big Screen
|
||||
|
||||
// Primary colors
|
||||
$color-primary: #c41230;
|
||||
$color-primary-light: #e8384f;
|
||||
$color-primary-dark: #9a0e26;
|
||||
|
||||
$color-gold: #d4a84b;
|
||||
$color-gold-light: #f0c96a;
|
||||
$color-gold-dark: #b8923f;
|
||||
|
||||
// Background
|
||||
$color-bg-dark: #0a0a0a;
|
||||
$color-bg-gradient: linear-gradient(180deg, #1a0a0a 0%, #0a0a0a 50%, #0a0510 100%);
|
||||
|
||||
// Text
|
||||
$color-text-light: #ffffff;
|
||||
$color-text-muted: rgba(255, 255, 255, 0.6);
|
||||
|
||||
// Glow effects
|
||||
$glow-red: 0 0 20px rgba($color-primary, 0.5), 0 0 40px rgba($color-primary, 0.3);
|
||||
$glow-gold: 0 0 20px rgba($color-gold, 0.5), 0 0 40px rgba($color-gold, 0.3);
|
||||
|
||||
// Animations
|
||||
$transition-fast: 0.2s ease;
|
||||
$transition-normal: 0.4s ease;
|
||||
$transition-slow: 0.8s ease;
|
||||
16
packages/client-screen/src/env.d.ts
vendored
Normal file
16
packages/client-screen/src/env.d.ts
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue';
|
||||
const component: DefineComponent<{}, {}, any>;
|
||||
export default component;
|
||||
}
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_URL: string;
|
||||
readonly VITE_SOCKET_URL: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
14
packages/client-screen/src/main.ts
Normal file
14
packages/client-screen/src/main.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { createApp } from 'vue';
|
||||
import { createPinia } from 'pinia';
|
||||
import App from './App.vue';
|
||||
import router from './router';
|
||||
|
||||
// Global styles
|
||||
import './assets/styles/global.scss';
|
||||
|
||||
const app = createApp(App);
|
||||
|
||||
app.use(createPinia());
|
||||
app.use(router);
|
||||
|
||||
app.mount('#app');
|
||||
29
packages/client-screen/src/router/index.ts
Normal file
29
packages/client-screen/src/router/index.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router';
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
name: 'main',
|
||||
component: () => import('../views/MainDisplay.vue'),
|
||||
},
|
||||
{
|
||||
path: '/draw',
|
||||
name: 'draw',
|
||||
component: () => import('../views/LuckyDrawView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/results',
|
||||
name: 'results',
|
||||
component: () => import('../views/VoteResultsView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/admin',
|
||||
name: 'admin',
|
||||
component: () => import('../views/AdminControl.vue'),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
export default router;
|
||||
158
packages/client-screen/src/stores/display.ts
Normal file
158
packages/client-screen/src/stores/display.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref, computed, shallowRef } from 'vue';
|
||||
import { io, Socket } from 'socket.io-client';
|
||||
import type {
|
||||
ServerToClientEvents,
|
||||
ClientToServerEvents,
|
||||
DrawStartPayload,
|
||||
DrawWinnerPayload,
|
||||
VoteUpdatePayload,
|
||||
} from '@gala/shared/types';
|
||||
import { SOCKET_EVENTS } from '@gala/shared/constants';
|
||||
|
||||
type GalaSocket = Socket<ServerToClientEvents, ClientToServerEvents>;
|
||||
|
||||
export const useDisplayStore = defineStore('display', () => {
|
||||
// State - use shallowRef for socket to avoid deep reactivity issues
|
||||
const socket = shallowRef<GalaSocket | null>(null);
|
||||
const isConnected = ref(false);
|
||||
const isConnecting = ref(false);
|
||||
const onlineUsers = ref(0);
|
||||
const currentMode = ref<'idle' | 'voting' | 'draw' | 'results'>('idle');
|
||||
|
||||
// Draw state
|
||||
const isDrawing = ref(false);
|
||||
const currentPrize = ref<string | null>(null);
|
||||
const currentWinner = ref<{
|
||||
userId: string;
|
||||
userName: string;
|
||||
department: string;
|
||||
avatar?: string;
|
||||
} | null>(null);
|
||||
|
||||
// Computed
|
||||
const connectionStatus = computed(() => {
|
||||
if (isConnected.value) return 'connected';
|
||||
if (isConnecting.value) return 'connecting';
|
||||
return 'disconnected';
|
||||
});
|
||||
|
||||
/**
|
||||
* Connect to WebSocket server
|
||||
*/
|
||||
function connect() {
|
||||
if (socket.value?.connected || isConnecting.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
isConnecting.value = true;
|
||||
|
||||
const socketInstance = io(import.meta.env.VITE_SOCKET_URL || '', {
|
||||
reconnection: true,
|
||||
reconnectionAttempts: Infinity,
|
||||
reconnectionDelay: 1000,
|
||||
reconnectionDelayMax: 5000,
|
||||
timeout: 10000,
|
||||
transports: ['websocket', 'polling'],
|
||||
});
|
||||
|
||||
// Connection events
|
||||
socketInstance.on('connect', () => {
|
||||
console.log('[Screen] Connected');
|
||||
isConnected.value = true;
|
||||
isConnecting.value = false;
|
||||
|
||||
// Join as screen
|
||||
socketInstance.emit(SOCKET_EVENTS.CONNECTION_JOIN as any, {
|
||||
userId: 'screen_main',
|
||||
userName: 'Main Display',
|
||||
role: 'screen',
|
||||
}, () => {});
|
||||
});
|
||||
|
||||
socketInstance.on('disconnect', (reason) => {
|
||||
console.log('[Screen] Disconnected:', reason);
|
||||
isConnected.value = false;
|
||||
});
|
||||
|
||||
socketInstance.on('connect_error', (error) => {
|
||||
console.error('[Screen] Connection error:', error);
|
||||
isConnecting.value = false;
|
||||
});
|
||||
|
||||
// User count updates
|
||||
socketInstance.on('connection:users_count' as any, (count: number) => {
|
||||
onlineUsers.value = count;
|
||||
});
|
||||
|
||||
// Draw events
|
||||
socketInstance.on('draw:started' as any, (data: DrawStartPayload) => {
|
||||
isDrawing.value = true;
|
||||
currentPrize.value = data.prizeName;
|
||||
currentWinner.value = null;
|
||||
currentMode.value = 'draw';
|
||||
});
|
||||
|
||||
socketInstance.on('draw:winner' as any, (data: DrawWinnerPayload) => {
|
||||
currentWinner.value = data.winner;
|
||||
});
|
||||
|
||||
socketInstance.on('draw:ended' as any, () => {
|
||||
isDrawing.value = false;
|
||||
});
|
||||
|
||||
// Vote updates
|
||||
socketInstance.on('vote:updated' as any, (data: VoteUpdatePayload) => {
|
||||
// Emit custom event for components to handle
|
||||
window.dispatchEvent(new CustomEvent('vote:updated', { detail: data }));
|
||||
});
|
||||
|
||||
socket.value = socketInstance as GalaSocket;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from server
|
||||
*/
|
||||
function disconnect() {
|
||||
if (socket.value) {
|
||||
socket.value.disconnect();
|
||||
socket.value = null;
|
||||
}
|
||||
isConnected.value = false;
|
||||
isConnecting.value = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set display mode
|
||||
*/
|
||||
function setMode(mode: 'idle' | 'voting' | 'draw' | 'results') {
|
||||
currentMode.value = mode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get socket instance (for advanced usage)
|
||||
*/
|
||||
function getSocket(): GalaSocket | null {
|
||||
return socket.value;
|
||||
}
|
||||
|
||||
return {
|
||||
// State (excluding socket to avoid type inference issues)
|
||||
isConnected,
|
||||
isConnecting,
|
||||
onlineUsers,
|
||||
currentMode,
|
||||
isDrawing,
|
||||
currentPrize,
|
||||
currentWinner,
|
||||
|
||||
// Computed
|
||||
connectionStatus,
|
||||
|
||||
// Actions
|
||||
connect,
|
||||
disconnect,
|
||||
setMode,
|
||||
getSocket,
|
||||
};
|
||||
});
|
||||
121
packages/client-screen/src/views/AdminControl.vue
Normal file
121
packages/client-screen/src/views/AdminControl.vue
Normal file
@@ -0,0 +1,121 @@
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
function goBack() {
|
||||
router.push('/');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="admin-control">
|
||||
<header class="header">
|
||||
<button class="back-btn" @click="goBack">← 返回</button>
|
||||
<h1 class="title">管理控制台</h1>
|
||||
</header>
|
||||
|
||||
<main class="content">
|
||||
<div class="control-panel">
|
||||
<h2>抽奖控制</h2>
|
||||
<div class="controls">
|
||||
<button class="control-btn">开始抽奖</button>
|
||||
<button class="control-btn danger">停止抽奖</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="control-panel">
|
||||
<h2>显示模式</h2>
|
||||
<div class="controls">
|
||||
<button class="control-btn">待机画面</button>
|
||||
<button class="control-btn">投票结果</button>
|
||||
<button class="control-btn">抽奖画面</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '../assets/styles/variables.scss';
|
||||
|
||||
.admin-control {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #1a1a1a;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
padding: 20px 30px;
|
||||
border-bottom: 1px solid #333;
|
||||
|
||||
.back-btn {
|
||||
background: none;
|
||||
border: 1px solid #666;
|
||||
color: #999;
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
border-color: #999;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 30px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 30px;
|
||||
}
|
||||
|
||||
.control-panel {
|
||||
background: #222;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
|
||||
h2 {
|
||||
font-size: 18px;
|
||||
margin-bottom: 16px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.control-btn {
|
||||
padding: 12px 24px;
|
||||
background: #333;
|
||||
border: 1px solid #444;
|
||||
color: white;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: #444;
|
||||
}
|
||||
|
||||
&.danger {
|
||||
border-color: #ff4d4f;
|
||||
color: #ff4d4f;
|
||||
|
||||
&:hover {
|
||||
background: rgba(#ff4d4f, 0.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
326
packages/client-screen/src/views/LuckyDrawView.vue
Normal file
326
packages/client-screen/src/views/LuckyDrawView.vue
Normal file
@@ -0,0 +1,326 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useDisplayStore } from '../stores/display';
|
||||
|
||||
const router = useRouter();
|
||||
const displayStore = useDisplayStore();
|
||||
|
||||
// Draw state
|
||||
const isSpinning = ref(false);
|
||||
const spinningNames = ref<string[]>([]);
|
||||
const winner = ref<{ name: string; department: string } | null>(null);
|
||||
|
||||
// Mock participants for demo
|
||||
const mockParticipants = [
|
||||
{ name: '张三', department: '技术部' },
|
||||
{ name: '李四', department: '产品部' },
|
||||
{ name: '王五', department: '设计部' },
|
||||
{ name: '赵六', department: '市场部' },
|
||||
{ name: '钱七', department: '运营部' },
|
||||
{ name: '孙八', department: '人事部' },
|
||||
{ name: '周九', department: '财务部' },
|
||||
{ name: '吴十', department: '销售部' },
|
||||
];
|
||||
|
||||
let spinInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
function startDraw() {
|
||||
if (isSpinning.value) return;
|
||||
|
||||
isSpinning.value = true;
|
||||
winner.value = null;
|
||||
|
||||
// Start spinning animation
|
||||
let speed = 50;
|
||||
let index = 0;
|
||||
|
||||
spinInterval = setInterval(() => {
|
||||
spinningNames.value = [
|
||||
mockParticipants[(index) % mockParticipants.length].name,
|
||||
mockParticipants[(index + 1) % mockParticipants.length].name,
|
||||
mockParticipants[(index + 2) % mockParticipants.length].name,
|
||||
];
|
||||
index++;
|
||||
}, speed);
|
||||
|
||||
// Slow down and stop after 5 seconds
|
||||
setTimeout(() => {
|
||||
slowDownAndStop();
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function slowDownAndStop() {
|
||||
if (spinInterval) {
|
||||
clearInterval(spinInterval);
|
||||
}
|
||||
|
||||
// Pick random winner
|
||||
const winnerIndex = Math.floor(Math.random() * mockParticipants.length);
|
||||
winner.value = mockParticipants[winnerIndex];
|
||||
isSpinning.value = false;
|
||||
spinningNames.value = [];
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
router.push('/');
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
if (spinInterval) {
|
||||
clearInterval(spinInterval);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="lucky-draw-view">
|
||||
<!-- Header -->
|
||||
<header class="header">
|
||||
<button class="back-btn" @click="goBack">← 返回</button>
|
||||
<h1 class="title gold-text">幸运抽奖</h1>
|
||||
<div class="online">{{ displayStore.onlineUsers }} 人在线</div>
|
||||
</header>
|
||||
|
||||
<!-- Main draw area -->
|
||||
<main class="draw-area">
|
||||
<!-- Prize display -->
|
||||
<div class="prize-section">
|
||||
<div class="prize-badge">
|
||||
<span class="level">特等奖</span>
|
||||
<span class="name">iPhone 16 Pro Max</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Spinning names -->
|
||||
<div class="spin-section">
|
||||
<div v-if="isSpinning" class="spinning-names">
|
||||
<div v-for="(name, i) in spinningNames" :key="i" class="name-item">
|
||||
{{ name }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Winner display -->
|
||||
<div v-else-if="winner" class="winner-display">
|
||||
<div class="winner-card">
|
||||
<div class="avatar">🎉</div>
|
||||
<div class="info">
|
||||
<h2 class="name gold-text">{{ winner.name }}</h2>
|
||||
<p class="department">{{ winner.department }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="congrats">恭喜中奖!</p>
|
||||
</div>
|
||||
|
||||
<!-- Idle state -->
|
||||
<div v-else class="idle-state">
|
||||
<p>点击下方按钮开始抽奖</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Draw button -->
|
||||
<div class="action-section">
|
||||
<button
|
||||
class="draw-btn"
|
||||
:class="{ spinning: isSpinning }"
|
||||
:disabled="isSpinning"
|
||||
@click="startDraw"
|
||||
>
|
||||
{{ isSpinning ? '抽奖中...' : '开始抽奖' }}
|
||||
</button>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '../assets/styles/variables.scss';
|
||||
|
||||
.lucky-draw-view {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: $color-bg-gradient;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 30px 50px;
|
||||
|
||||
.back-btn {
|
||||
background: none;
|
||||
border: 1px solid $color-gold;
|
||||
color: $color-gold;
|
||||
padding: 10px 20px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
transition: all $transition-fast;
|
||||
|
||||
&:hover {
|
||||
background: rgba($color-gold, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 48px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.online {
|
||||
color: $color-text-muted;
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.draw-area {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 50px;
|
||||
}
|
||||
|
||||
.prize-section {
|
||||
margin-bottom: 60px;
|
||||
|
||||
.prize-badge {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 30px 60px;
|
||||
border: 2px solid $color-gold;
|
||||
border-radius: 16px;
|
||||
background: rgba($color-gold, 0.1);
|
||||
|
||||
.level {
|
||||
font-size: 24px;
|
||||
color: $color-gold;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: 36px;
|
||||
color: $color-text-light;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.spin-section {
|
||||
min-height: 300px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-bottom: 60px;
|
||||
|
||||
.spinning-names {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
|
||||
.name-item {
|
||||
font-size: 72px;
|
||||
color: $color-text-light;
|
||||
text-align: center;
|
||||
animation: flash 0.1s infinite;
|
||||
|
||||
&:nth-child(2) {
|
||||
font-size: 96px;
|
||||
color: $color-gold;
|
||||
text-shadow: $glow-gold;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.winner-display {
|
||||
text-align: center;
|
||||
|
||||
.winner-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
padding: 40px 80px;
|
||||
border: 3px solid $color-gold;
|
||||
border-radius: 20px;
|
||||
background: rgba($color-gold, 0.1);
|
||||
animation: scale-pulse 1s ease-in-out infinite;
|
||||
|
||||
.avatar {
|
||||
font-size: 80px;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: 64px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.department {
|
||||
font-size: 24px;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
}
|
||||
|
||||
.congrats {
|
||||
margin-top: 30px;
|
||||
font-size: 36px;
|
||||
color: $color-primary-light;
|
||||
text-shadow: $glow-red;
|
||||
}
|
||||
}
|
||||
|
||||
.idle-state {
|
||||
font-size: 24px;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
}
|
||||
|
||||
.action-section {
|
||||
.draw-btn {
|
||||
padding: 20px 80px;
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
color: $color-text-light;
|
||||
background: linear-gradient(135deg, $color-primary-light 0%, $color-primary 100%);
|
||||
border: none;
|
||||
border-radius: 50px;
|
||||
cursor: pointer;
|
||||
transition: all $transition-normal;
|
||||
box-shadow: $glow-red;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&.spinning {
|
||||
animation: glow-pulse 0.5s infinite;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes flash {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.7; }
|
||||
}
|
||||
|
||||
@keyframes scale-pulse {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.02); }
|
||||
}
|
||||
|
||||
@keyframes glow-pulse {
|
||||
0%, 100% { box-shadow: $glow-red; }
|
||||
50% { box-shadow: 0 0 40px rgba($color-primary, 0.8), 0 0 80px rgba($color-primary, 0.5); }
|
||||
}
|
||||
</style>
|
||||
248
packages/client-screen/src/views/MainDisplay.vue
Normal file
248
packages/client-screen/src/views/MainDisplay.vue
Normal file
@@ -0,0 +1,248 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useDisplayStore } from '../stores/display';
|
||||
|
||||
const router = useRouter();
|
||||
const displayStore = useDisplayStore();
|
||||
|
||||
const currentTime = ref(new Date().toLocaleTimeString('zh-CN'));
|
||||
|
||||
// Update time every second
|
||||
onMounted(() => {
|
||||
setInterval(() => {
|
||||
currentTime.value = new Date().toLocaleTimeString('zh-CN');
|
||||
}, 1000);
|
||||
});
|
||||
|
||||
function goToDraw() {
|
||||
router.push('/draw');
|
||||
}
|
||||
|
||||
function goToResults() {
|
||||
router.push('/results');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="main-display">
|
||||
<!-- Background particles will be added via Pixi.js -->
|
||||
<div class="background-overlay"></div>
|
||||
|
||||
<!-- Header -->
|
||||
<header class="header">
|
||||
<div class="logo">
|
||||
<span class="year gold-text">2026</span>
|
||||
<span class="title">年会盛典</span>
|
||||
</div>
|
||||
<div class="status">
|
||||
<span class="online-count">
|
||||
<span class="dot"></span>
|
||||
{{ displayStore.onlineUsers }} 人在线
|
||||
</span>
|
||||
<span class="time">{{ currentTime }}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Main content -->
|
||||
<main class="content">
|
||||
<div class="welcome-section">
|
||||
<h1 class="main-title">
|
||||
<span class="gold-text">马到成功</span>
|
||||
</h1>
|
||||
<p class="subtitle">2026 年度盛典</p>
|
||||
</div>
|
||||
|
||||
<!-- Action buttons -->
|
||||
<div class="actions">
|
||||
<button class="action-btn draw-btn" @click="goToDraw">
|
||||
<span class="icon">🎁</span>
|
||||
<span class="text">幸运抽奖</span>
|
||||
</button>
|
||||
<button class="action-btn results-btn" @click="goToResults">
|
||||
<span class="icon">📊</span>
|
||||
<span class="text">投票结果</span>
|
||||
</button>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Decorative elements -->
|
||||
<div class="decoration left-lantern"></div>
|
||||
<div class="decoration right-lantern"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '../assets/styles/variables.scss';
|
||||
|
||||
.main-display {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: $color-bg-gradient;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.background-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: radial-gradient(ellipse at center, transparent 0%, rgba(0, 0, 0, 0.5) 100%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 30px 50px;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 16px;
|
||||
|
||||
.year {
|
||||
font-size: 48px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 24px;
|
||||
color: $color-text-light;
|
||||
letter-spacing: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 30px;
|
||||
font-size: 18px;
|
||||
color: $color-text-muted;
|
||||
|
||||
.online-count {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: #52c41a;
|
||||
border-radius: 50%;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.welcome-section {
|
||||
text-align: center;
|
||||
margin-bottom: 80px;
|
||||
|
||||
.main-title {
|
||||
font-size: 120px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 20px;
|
||||
text-shadow: $glow-gold;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 36px;
|
||||
color: $color-text-muted;
|
||||
letter-spacing: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 60px;
|
||||
|
||||
.action-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 40px 60px;
|
||||
border: 2px solid $color-gold;
|
||||
border-radius: 20px;
|
||||
background: rgba($color-gold, 0.1);
|
||||
cursor: pointer;
|
||||
transition: all $transition-normal;
|
||||
|
||||
&:hover {
|
||||
background: rgba($color-gold, 0.2);
|
||||
transform: translateY(-5px);
|
||||
box-shadow: $glow-gold;
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 64px;
|
||||
}
|
||||
|
||||
.text {
|
||||
font-size: 24px;
|
||||
color: $color-gold;
|
||||
letter-spacing: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.draw-btn {
|
||||
border-color: $color-primary;
|
||||
background: rgba($color-primary, 0.1);
|
||||
|
||||
&:hover {
|
||||
background: rgba($color-primary, 0.2);
|
||||
box-shadow: $glow-red;
|
||||
}
|
||||
|
||||
.text {
|
||||
color: $color-primary-light;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.decoration {
|
||||
position: absolute;
|
||||
width: 80px;
|
||||
height: 120px;
|
||||
background: linear-gradient(180deg, $color-primary 0%, $color-primary-dark 100%);
|
||||
border-radius: 50% 50% 45% 45%;
|
||||
opacity: 0.6;
|
||||
|
||||
&.left-lantern {
|
||||
top: 100px;
|
||||
left: 50px;
|
||||
animation: float 4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
&.right-lantern {
|
||||
top: 100px;
|
||||
right: 50px;
|
||||
animation: float 4s ease-in-out infinite 1s;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translateY(0) rotate(-3deg); }
|
||||
50% { transform: translateY(-15px) rotate(3deg); }
|
||||
}
|
||||
</style>
|
||||
201
packages/client-screen/src/views/VoteResultsView.vue
Normal file
201
packages/client-screen/src/views/VoteResultsView.vue
Normal file
@@ -0,0 +1,201 @@
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
function goBack() {
|
||||
router.push('/');
|
||||
}
|
||||
|
||||
// Mock vote results
|
||||
const categories = [
|
||||
{
|
||||
name: '最佳员工',
|
||||
results: [
|
||||
{ name: '张三', votes: 45, percentage: 30 },
|
||||
{ name: '李四', votes: 38, percentage: 25 },
|
||||
{ name: '王五', votes: 32, percentage: 21 },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: '最佳团队',
|
||||
results: [
|
||||
{ name: '技术一组', votes: 52, percentage: 35 },
|
||||
{ name: '产品组', votes: 41, percentage: 27 },
|
||||
{ name: '设计组', votes: 35, percentage: 23 },
|
||||
],
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="vote-results-view">
|
||||
<!-- Header -->
|
||||
<header class="header">
|
||||
<button class="back-btn" @click="goBack">← 返回</button>
|
||||
<h1 class="title gold-text">投票结果</h1>
|
||||
<div class="placeholder"></div>
|
||||
</header>
|
||||
|
||||
<!-- Results grid -->
|
||||
<main class="results-grid">
|
||||
<div v-for="category in categories" :key="category.name" class="category-card">
|
||||
<h2 class="category-name">{{ category.name }}</h2>
|
||||
<div class="results-list">
|
||||
<div
|
||||
v-for="(result, index) in category.results"
|
||||
:key="result.name"
|
||||
class="result-item"
|
||||
:class="{ winner: index === 0 }"
|
||||
>
|
||||
<span class="rank">{{ index + 1 }}</span>
|
||||
<span class="name">{{ result.name }}</span>
|
||||
<div class="bar-container">
|
||||
<div class="bar" :style="{ width: result.percentage + '%' }"></div>
|
||||
</div>
|
||||
<span class="votes">{{ result.votes }}票</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '../assets/styles/variables.scss';
|
||||
|
||||
.vote-results-view {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: $color-bg-gradient;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 30px 50px;
|
||||
|
||||
.back-btn {
|
||||
background: none;
|
||||
border: 1px solid $color-gold;
|
||||
color: $color-gold;
|
||||
padding: 10px 20px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
transition: all $transition-fast;
|
||||
|
||||
&:hover {
|
||||
background: rgba($color-gold, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 48px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
width: 100px;
|
||||
}
|
||||
}
|
||||
|
||||
.results-grid {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 40px;
|
||||
padding: 40px 50px;
|
||||
}
|
||||
|
||||
.category-card {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba($color-gold, 0.3);
|
||||
border-radius: 16px;
|
||||
padding: 30px;
|
||||
|
||||
.category-name {
|
||||
font-size: 28px;
|
||||
color: $color-gold;
|
||||
margin-bottom: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.results-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.result-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-radius: 8px;
|
||||
|
||||
&.winner {
|
||||
background: rgba($color-gold, 0.1);
|
||||
border: 1px solid $color-gold;
|
||||
|
||||
.rank {
|
||||
background: $color-gold;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.name {
|
||||
color: $color-gold;
|
||||
}
|
||||
|
||||
.bar {
|
||||
background: linear-gradient(90deg, $color-gold-dark, $color-gold);
|
||||
}
|
||||
}
|
||||
|
||||
.rank {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 50%;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.name {
|
||||
width: 120px;
|
||||
font-size: 20px;
|
||||
color: $color-text-light;
|
||||
}
|
||||
|
||||
.bar-container {
|
||||
flex: 1;
|
||||
height: 24px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
|
||||
.bar {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, $color-primary-dark, $color-primary);
|
||||
border-radius: 12px;
|
||||
transition: width 1s ease;
|
||||
}
|
||||
}
|
||||
|
||||
.votes {
|
||||
width: 60px;
|
||||
text-align: right;
|
||||
font-size: 18px;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
20
packages/client-screen/tsconfig.json
Normal file
20
packages/client-screen/tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "preserve",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "env.d.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
41
packages/client-screen/vite.config.ts
Normal file
41
packages/client-screen/vite.config.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
import { resolve } from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, 'src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5174,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/socket.io': {
|
||||
target: 'http://localhost:3000',
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
target: 'es2020',
|
||||
minify: 'terser',
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
vue: ['vue', 'vue-router', 'pinia'],
|
||||
pixi: ['pixi.js'],
|
||||
gsap: ['gsap'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
optimizeDeps: {
|
||||
include: ['pixi.js', 'gsap'],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user