feat: 新增签到墙、摇一摇等功能及开发环境配置

新功能:
- 签到墙页面 (CheckinWallView) 及后端接口
- 摇一摇互动页面 (ShakeView) 及服务
- 头像服务 (avatar.service)
- 微信公众号静默授权登录增强

开发环境:
- 新增 dev-tunnel skill 用于本地调试
- docker-compose.dev.yml 开发环境配置
- 客户端 .env.development 配置文件

其他改进:
- VoteView 投票页面功能增强
- AdminControl 管理控制台更新
- 连接状态管理优化
- 新增马蹄声音效

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
empty
2026-02-04 17:25:56 +08:00
parent c1b4b09e40
commit 48d61a1e15
38 changed files with 2020 additions and 27 deletions

View File

@@ -0,0 +1,3 @@
VITE_SOCKET_URL=http://localhost:3000
VITE_API_URL=http://localhost:3000
VITE_MOBILE_URL=https://2026.cptp.let5see.xyz

Binary file not shown.

View File

@@ -16,6 +16,7 @@ const modeRoutes: Record<string, string> = {
'draw': '/screen/draw',
'results': '/screen/results',
'lottery_results': '/screen/lottery-results',
'checkin_wall': '/screen/checkin-wall',
};
// Unlock audio playback (required by browser autoplay policy)

View File

@@ -57,6 +57,12 @@ const router = createRouter({
component: () => import('../views/HorseRaceView.vue'),
meta: { title: '年会大屏 - 赛马热度' },
},
{
path: '/screen/checkin-wall',
name: 'screen-checkin-wall',
component: () => import('../views/CheckinWallView.vue'),
meta: { title: '年会大屏 - 签到墙' },
},
// Legacy routes (redirect to new paths)
{ path: '/voting', redirect: '/screen/voting' },

View File

@@ -31,6 +31,7 @@ export const useAdminStore = defineStore('admin', () => {
// Admin State (mirrors server state)
const systemPhase = ref<SystemPhase>('IDLE');
const eventTitle = ref('公司2026年会');
const votingOpen = ref(false);
const votingPaused = ref(false);
const totalVotes = ref(0);
@@ -232,6 +233,7 @@ export const useAdminStore = defineStore('admin', () => {
function syncFromServer(state: AdminState) {
systemPhase.value = state.systemPhase;
eventTitle.value = state.eventTitle || '公司2026年会';
votingOpen.value = state.voting.subPhase === 'OPEN';
votingPaused.value = state.voting.subPhase === 'PAUSED';
totalVotes.value = state.voting.totalVotes;
@@ -275,6 +277,23 @@ export const useAdminStore = defineStore('admin', () => {
});
}
function setEventTitle(title: string) {
if (!socket.value?.connected) return;
pendingAction.value = 'event_title_update';
socket.value.emit(SOCKET_EVENTS.ADMIN_EVENT_TITLE_UPDATE as any, {
title,
}, (response: any) => {
pendingAction.value = null;
if (response.success) {
eventTitle.value = title;
lastActionTime.value = Date.now();
} else {
lastError.value = response.message || 'Failed to update event title';
}
});
}
function controlVoting(action: 'open' | 'close' | 'pause' | 'resume') {
if (!socket.value?.connected) return;
pendingAction.value = `voting_${action}`;
@@ -420,6 +439,7 @@ export const useAdminStore = defineStore('admin', () => {
// State
systemPhase,
eventTitle,
votingOpen,
votingPaused,
totalVotes,
@@ -448,6 +468,7 @@ export const useAdminStore = defineStore('admin', () => {
connect,
disconnect,
setPhase,
setEventTitle,
controlVoting,
controlLottery,
emergencyReset,

View File

@@ -23,6 +23,7 @@ const AUDIO_TRACKS: Record<string, string> = {
lottery: '/screen/audio/lottery.mp3',
fanfare: '/screen/audio/fanfare.mp3',
award: '/screen/audio/award.mp3',
horse: '/screen/audio/horse.mp3', // 第四轮抽奖"属马"专属音效
};
export const useDisplayStore = defineStore('display', () => {
@@ -31,7 +32,8 @@ export const useDisplayStore = defineStore('display', () => {
const isConnected = ref(false);
const isConnecting = ref(false);
const onlineUsers = ref(0);
const currentMode = ref<'idle' | 'voting' | 'draw' | 'results' | 'lottery_results'>('idle');
const currentMode = ref<'idle' | 'voting' | 'draw' | 'results' | 'lottery_results' | 'checkin_wall'>('idle');
const eventTitle = ref('公司2026年会');
// Draw state
const isDrawing = ref(false);
@@ -131,13 +133,19 @@ export const useDisplayStore = defineStore('display', () => {
socketInstance.on(SOCKET_EVENTS.ADMIN_STATE_SYNC as any, (state: AdminState) => {
console.log('[Screen] Admin state sync received:', state.systemPhase);
// Update event title if changed
if (state.eventTitle && state.eventTitle !== eventTitle.value) {
eventTitle.value = state.eventTitle;
}
// Map SystemPhase to display mode
const phaseToMode: Record<SystemPhase, 'idle' | 'voting' | 'draw' | 'results' | 'lottery_results'> = {
const phaseToMode: Record<SystemPhase, 'idle' | 'voting' | 'draw' | 'results' | 'lottery_results' | 'checkin_wall'> = {
'IDLE': 'idle',
'VOTING': 'voting',
'LOTTERY': 'draw',
'RESULTS': 'results',
'LOTTERY_RESULTS': 'lottery_results',
'CHECKIN_WALL': 'checkin_wall',
};
const newMode = phaseToMode[state.systemPhase] || 'idle';
@@ -247,6 +255,7 @@ export const useDisplayStore = defineStore('display', () => {
isConnecting,
onlineUsers,
currentMode,
eventTitle,
isDrawing,
currentPrize,
currentWinner,

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { ref, computed, onMounted, watch } from 'vue';
import { useRouter } from 'vue-router';
import { useAdminStore } from '../stores/admin';
import { PRIZE_CONFIG } from '@gala/shared/types';
@@ -38,6 +38,20 @@ const router = useRouter();
const admin = useAdminStore();
const adminToken = () => localStorage.getItem(ADMIN_TOKEN_KEY) || '';
// Event title editing
const editingEventTitle = ref(admin.eventTitle);
// 监听服务器同步的 eventTitle确保刷新后显示正确的值
watch(() => admin.eventTitle, (newTitle) => {
editingEventTitle.value = newTitle;
}, { immediate: true });
function saveEventTitle() {
if (editingEventTitle.value && editingEventTitle.value !== admin.eventTitle) {
admin.setEventTitle(editingEventTitle.value);
}
}
function getAdminHeaders(extra?: Record<string, string>) {
return {
'Content-Type': 'application/json',
@@ -398,7 +412,7 @@ function handleLogout() {
}
// Phase control
function setPhase(phase: 'IDLE' | 'VOTING' | 'LOTTERY' | 'RESULTS' | 'LOTTERY_RESULTS') {
function setPhase(phase: 'IDLE' | 'VOTING' | 'LOTTERY' | 'RESULTS' | 'LOTTERY_RESULTS' | 'CHECKIN_WALL') {
admin.setPhase(phase);
}
@@ -615,6 +629,7 @@ const phaseLabel = computed(() => {
case 'LOTTERY': return '抽奖中';
case 'RESULTS': return '结果展示';
case 'LOTTERY_RESULTS': return '抽奖结果';
case 'CHECKIN_WALL': return '签到墙';
default: return '未知';
}
});
@@ -1057,6 +1072,22 @@ onMounted(() => {
</div>
<div class="section-body">
<!-- Event Title -->
<div class="control-group">
<h4>活动名称</h4>
<div class="input-group">
<input
type="text"
v-model="editingEventTitle"
class="text-input"
placeholder="输入活动名称"
@blur="saveEventTitle"
@keyup.enter="saveEventTitle"
/>
<button class="ctrl-btn primary" @click="saveEventTitle">保存</button>
</div>
</div>
<!-- Display Mode -->
<div class="control-group">
<h4>显示模式</h4>
@@ -1096,6 +1127,13 @@ onMounted(() => {
>
抽奖结果
</button>
<button
class="ctrl-btn"
:class="{ active: admin.systemPhase === 'CHECKIN_WALL' }"
@click="setPhase('CHECKIN_WALL')"
>
签到墙
</button>
</div>
</div>
@@ -1743,6 +1781,34 @@ $admin-danger: #ef4444;
gap: 12px;
}
// Input Groups
.input-group {
display: flex;
gap: 12px;
align-items: center;
}
.text-input {
flex: 1;
padding: 10px 16px;
font-size: 14px;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 8px;
background: rgba(255, 255, 255, 0.05);
color: $admin-text;
outline: none;
transition: border-color 0.2s, background 0.2s;
&:focus {
border-color: $admin-primary;
background: rgba(255, 255, 255, 0.1);
}
&::placeholder {
color: rgba(255, 255, 255, 0.4);
}
}
.ctrl-btn {
padding: 10px 20px;
font-size: 14px;

View File

@@ -0,0 +1,413 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue';
import { useSocketClient } from '../composables/useSocketClient';
import { useDisplayStore } from '../stores/display';
import { SOCKET_EVENTS } from '@gala/shared/constants';
import type { CheckinUser, ShakeUpdatePayload } from '@gala/shared/types';
const { getSocket, isConnected, connect } = useSocketClient();
const displayStore = useDisplayStore();
// 签到用户列表
const users = ref<CheckinUser[]>([]);
const isLoading = ref(true);
// 总摇动次数
const totalShakeCount = computed(() => {
return users.value.reduce((sum, user) => sum + user.shakeCount, 0);
});
// 容器尺寸
const containerWidth = ref(1920);
const containerHeight = ref(1080);
// 泡泡位置缓存
const bubblePositions = ref<Map<string, { x: number; y: number; size: number }>>(new Map());
// 计算泡泡大小(基于摇动次数)
function calculateBubbleSize(shakeCount: number, maxShakeCount: number): number {
const minSize = 60;
const maxSize = 120;
if (maxShakeCount === 0) return minSize;
const ratio = Math.min(shakeCount / Math.max(maxShakeCount, 1), 1);
return minSize + (maxSize - minSize) * Math.sqrt(ratio);
}
// 检查两个圆是否重叠
function circlesOverlap(
x1: number, y1: number, r1: number,
x2: number, y2: number, r2: number,
padding: number = 10
): boolean {
const dx = x2 - x1;
const dy = y2 - y1;
const distance = Math.sqrt(dx * dx + dy * dy);
return distance < r1 + r2 + padding;
}
// 计算泡泡位置避免重叠C位给摇动次数最高的
function calculateBubblePositions() {
const sorted = [...users.value].sort((a, b) => b.shakeCount - a.shakeCount);
const maxShakeCount = sorted[0]?.shakeCount || 0;
const positions = new Map<string, { x: number; y: number; size: number }>();
const placed: Array<{ x: number; y: number; size: number }> = [];
const centerX = containerWidth.value / 2;
const centerY = containerHeight.value / 2;
const padding = 80;
sorted.forEach((user, index) => {
const size = calculateBubbleSize(user.shakeCount, maxShakeCount);
let x: number, y: number;
let attempts = 0;
const maxAttempts = 100;
if (index === 0) {
// C位屏幕中心
x = centerX;
y = centerY;
} else {
// 螺旋布局
const angle = index * 0.5;
const radius = 100 + index * 30;
x = centerX + Math.cos(angle) * radius;
y = centerY + Math.sin(angle) * radius;
// 避免重叠
while (attempts < maxAttempts) {
let overlaps = false;
for (const p of placed) {
if (circlesOverlap(x, y, size / 2, p.x, p.y, p.size / 2)) {
overlaps = true;
break;
}
}
if (!overlaps) break;
// 调整位置
const newAngle = angle + (Math.random() - 0.5) * Math.PI;
const newRadius = radius + (Math.random() - 0.5) * 100;
x = centerX + Math.cos(newAngle) * newRadius;
y = centerY + Math.sin(newAngle) * newRadius;
attempts++;
}
}
// 边界检查
x = Math.max(size / 2 + padding, Math.min(containerWidth.value - size / 2 - padding, x));
y = Math.max(size / 2 + padding, Math.min(containerHeight.value - size / 2 - padding, y));
positions.set(user.id, { x, y, size });
placed.push({ x, y, size });
});
bubblePositions.value = positions;
}
// 获取用户泡泡样式
function getBubbleStyle(user: CheckinUser) {
const pos = bubblePositions.value.get(user.id);
if (!pos) return {};
return {
left: `${pos.x}px`,
top: `${pos.y}px`,
width: `${pos.size}px`,
height: `${pos.size}px`,
transform: 'translate(-50%, -50%)',
};
}
// 获取签到用户列表
async function fetchUsers() {
try {
const apiUrl = import.meta.env.VITE_API_URL || '';
const res = await fetch(`${apiUrl}/api/checkin/users`);
const data = await res.json();
if (data.success) {
users.value = data.data.users;
calculateBubblePositions();
}
} catch (error) {
console.error('Failed to fetch users:', error);
} finally {
isLoading.value = false;
}
}
// 处理摇动更新
function handleShakeUpdate(data: ShakeUpdatePayload) {
const index = users.value.findIndex(u => u.id === data.userId);
if (index >= 0) {
users.value[index].shakeCount = data.shakeCount;
if (data.avatar) {
users.value[index].avatar = data.avatar;
}
} else {
users.value.push({
id: data.userId,
name: data.userName,
avatar: data.avatar,
shakeCount: data.shakeCount,
checkinTime: Date.now(),
});
}
calculateBubblePositions();
}
onMounted(() => {
if (!isConnected.value) {
connect();
}
fetchUsers();
// 监听摇动更新
const socket = getSocket();
socket?.on(SOCKET_EVENTS.SHAKE_UPDATE as any, handleShakeUpdate);
// 定期刷新
const interval = setInterval(fetchUsers, 10000);
onUnmounted(() => {
clearInterval(interval);
const s = getSocket();
s?.off(SOCKET_EVENTS.SHAKE_UPDATE as any, handleShakeUpdate);
});
});
</script>
<template>
<div class="checkin-wall">
<!-- 头部信息栏 -->
<header class="header">
<div class="stats">
<span class="stat-item">
<span class="stat-value">{{ users.length }}</span>
<span class="stat-label">人已签到</span>
</span>
<span class="stat-item">
<span class="stat-value">{{ totalShakeCount }}</span>
<span class="stat-label">次摇动</span>
</span>
</div>
<h1 class="title gold-text">{{ displayStore.eventTitle }}</h1>
<div class="header-placeholder"></div>
</header>
<!-- 加载状态 -->
<div v-if="isLoading" class="loading">
<div class="loading-spinner"></div>
<p>加载中...</p>
</div>
<!-- 空状态 -->
<div v-else-if="users.length === 0" class="empty">
<p class="empty-text">暂无签到用户</p>
<p class="empty-hint">扫码登录后即可显示</p>
</div>
<!-- 泡泡容器 -->
<div v-else class="bubbles-container">
<TransitionGroup name="bubble">
<div
v-for="user in users"
:key="user.id"
class="bubble"
:style="getBubbleStyle(user)"
>
<img
v-if="user.avatar"
:src="user.avatar"
class="bubble-avatar"
:alt="user.name"
/>
<div v-else class="bubble-placeholder">
{{ user.name.charAt(0) }}
</div>
<div class="bubble-info">
<span class="bubble-name">{{ user.name }}</span>
<span v-if="user.shakeCount > 0" class="bubble-count">
{{ user.shakeCount }}
</span>
</div>
</div>
</TransitionGroup>
</div>
</div>
</template>
<style lang="scss" scoped>
@use '../assets/styles/variables.scss' as *;
.checkin-wall {
width: 100vw;
height: 100vh;
position: relative;
overflow: hidden;
background: radial-gradient(circle at center, #b91c1c 0%, #7f1d1d 100%);
// Paper texture overlay
&::before {
content: '';
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
background-image: url("https://www.transparenttextures.com/patterns/pinstriped-suit.png");
opacity: 0.1;
pointer-events: none;
}
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 30px 60px;
flex-shrink: 0;
z-index: 10;
.stats {
display: flex;
gap: 24px;
}
.title {
font-size: 48px;
font-weight: 800;
letter-spacing: 4px;
text-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.header-placeholder {
width: 140px; // Balance the stats section
}
}
.stat-item {
display: flex;
align-items: baseline;
gap: 8px;
}
.stat-value {
font-size: 36px;
font-weight: 700;
color: $color-gold;
}
.stat-label {
font-size: 18px;
color: rgba(255, 255, 255, 0.7);
}
// Loading & Empty states
.loading,
.empty {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 5;
color: rgba(255, 255, 255, 0.7);
}
.loading-spinner {
width: 48px;
height: 48px;
border: 4px solid rgba(255, 255, 255, 0.2);
border-top-color: $color-gold;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 16px;
}
.empty-text {
font-size: 24px;
margin-bottom: 8px;
}
.empty-hint {
font-size: 16px;
color: rgba(255, 255, 255, 0.5);
}
// Bubbles container
.bubbles-container {
position: absolute;
inset: 0;
z-index: 5;
}
.bubble {
position: absolute;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
transition: all 0.5s ease-out;
}
.bubble-avatar,
.bubble-placeholder {
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
border: 3px solid $color-gold;
box-shadow: 0 0 20px rgba($color-gold, 0.3);
}
.bubble-placeholder {
background: linear-gradient(135deg, #2d1515 0%, #1a0a0a 100%);
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
font-weight: 700;
color: $color-gold;
}
.bubble-info {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
margin-top: 8px;
}
.bubble-name {
font-size: 14px;
color: rgba(255, 255, 255, 0.9);
white-space: nowrap;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
}
.bubble-count {
font-size: 12px;
color: $color-gold;
font-weight: 600;
}
// Animations
.bubble-enter-active {
transition: all 0.5s ease-out;
}
.bubble-leave-active {
transition: all 0.3s ease-in;
}
.bubble-enter-from {
opacity: 0;
transform: translate(-50%, -50%) scale(0);
}
.bubble-leave-to {
opacity: 0;
transform: translate(-50%, -50%) scale(0);
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>

View File

@@ -12,7 +12,8 @@ export default defineConfig({
},
server: {
host: '0.0.0.0',
port: 5173,
port: 5174,
allowedHosts: ['2026.cptp.let5see.xyz'],
proxy: {
'/api': {
target: 'http://localhost:3000',