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:
2
packages/client-mobile/.env.development
Normal file
2
packages/client-mobile/.env.development
Normal file
@@ -0,0 +1,2 @@
|
||||
VITE_SOCKET_URL=https://2026.cptp.let5see.xyz
|
||||
VITE_API_URL=https://2026.cptp.let5see.xyz
|
||||
@@ -2,6 +2,9 @@
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
|
||||
<meta http-equiv="Pragma" content="no-cache" />
|
||||
<meta http-equiv="Expires" content="0" />
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<meta name="theme-color" content="#c41230" />
|
||||
|
||||
1
packages/client-mobile/src/components.d.ts
vendored
1
packages/client-mobile/src/components.d.ts
vendored
@@ -8,6 +8,7 @@ export {}
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
ConnectionStatus: typeof import('./components/ConnectionStatus.vue')['default']
|
||||
OnboardingTour: typeof import('./components/OnboardingTour.vue')['default']
|
||||
Postmark: typeof import('./components/Postmark.vue')['default']
|
||||
ProgramCard: typeof import('./components/ProgramCard.vue')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
|
||||
@@ -28,6 +28,11 @@ const router = createRouter({
|
||||
name: 'scan-login',
|
||||
component: () => import('../views/ScanLoginView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/shake',
|
||||
name: 'shake',
|
||||
component: () => import('../views/ShakeView.vue'),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
@@ -284,7 +284,7 @@ export const useConnectionStore = defineStore('connection', () => {
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout and clear stored user info
|
||||
* Logout and clear all stored data
|
||||
*/
|
||||
function logout() {
|
||||
// Clear state
|
||||
@@ -293,10 +293,25 @@ export const useConnectionStore = defineStore('connection', () => {
|
||||
votedCategories.value = [];
|
||||
sessionToken.value = null;
|
||||
|
||||
// Clear localStorage
|
||||
localStorage.removeItem(STORAGE_KEYS.USER_ID);
|
||||
localStorage.removeItem(STORAGE_KEYS.USER_NAME);
|
||||
localStorage.removeItem(STORAGE_KEYS.SESSION_TOKEN);
|
||||
// Clear all localStorage
|
||||
localStorage.clear();
|
||||
|
||||
// Clear sessionStorage
|
||||
sessionStorage.clear();
|
||||
|
||||
// Clear service worker cache (PWA)
|
||||
if ('caches' in window) {
|
||||
caches.keys().then((names) => {
|
||||
names.forEach((name) => caches.delete(name));
|
||||
});
|
||||
}
|
||||
|
||||
// Unregister service workers
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.getRegistrations().then((registrations) => {
|
||||
registrations.forEach((registration) => registration.unregister());
|
||||
});
|
||||
}
|
||||
|
||||
// Disconnect socket
|
||||
disconnect();
|
||||
|
||||
370
packages/client-mobile/src/views/ShakeView.vue
Normal file
370
packages/client-mobile/src/views/ShakeView.vue
Normal file
@@ -0,0 +1,370 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useConnectionStore } from '../stores/connection';
|
||||
import { SOCKET_EVENTS } from '@gala/shared/constants';
|
||||
|
||||
const router = useRouter();
|
||||
const connectionStore = useConnectionStore();
|
||||
|
||||
const shakeCount = ref(0);
|
||||
const isShaking = ref(false);
|
||||
const hasPermission = ref(false);
|
||||
const permissionDenied = ref(false);
|
||||
|
||||
// 摇一摇检测参数
|
||||
const SHAKE_THRESHOLD = 15;
|
||||
const SHAKE_COOLDOWN = 300;
|
||||
let lastShakeTime = 0;
|
||||
let lastX = 0, lastY = 0, lastZ = 0;
|
||||
|
||||
// 请求设备运动权限(iOS 13+)
|
||||
async function requestPermission() {
|
||||
if (typeof (DeviceMotionEvent as any).requestPermission === 'function') {
|
||||
try {
|
||||
const permission = await (DeviceMotionEvent as any).requestPermission();
|
||||
if (permission === 'granted') {
|
||||
permissionDenied.value = false;
|
||||
hasPermission.value = true;
|
||||
startListening();
|
||||
} else {
|
||||
permissionDenied.value = true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Permission request failed:', error);
|
||||
permissionDenied.value = true;
|
||||
}
|
||||
} else {
|
||||
// 非 iOS 或旧版本,直接开始监听
|
||||
hasPermission.value = true;
|
||||
startListening();
|
||||
}
|
||||
}
|
||||
|
||||
// 开始监听设备运动
|
||||
function startListening() {
|
||||
window.addEventListener('devicemotion', handleMotion);
|
||||
}
|
||||
|
||||
// 停止监听
|
||||
function stopListening() {
|
||||
window.removeEventListener('devicemotion', handleMotion);
|
||||
}
|
||||
|
||||
// 处理设备运动事件
|
||||
function handleMotion(event: DeviceMotionEvent) {
|
||||
const acceleration = event.accelerationIncludingGravity;
|
||||
if (!acceleration) return;
|
||||
|
||||
const { x, y, z } = acceleration;
|
||||
if (x === null || y === null || z === null) return;
|
||||
|
||||
const deltaX = Math.abs(x - lastX);
|
||||
const deltaY = Math.abs(y - lastY);
|
||||
const deltaZ = Math.abs(z - lastZ);
|
||||
|
||||
const totalDelta = deltaX + deltaY + deltaZ;
|
||||
|
||||
if (totalDelta > SHAKE_THRESHOLD) {
|
||||
const now = Date.now();
|
||||
if (now - lastShakeTime > SHAKE_COOLDOWN) {
|
||||
lastShakeTime = now;
|
||||
onShake();
|
||||
}
|
||||
}
|
||||
|
||||
lastX = x;
|
||||
lastY = y;
|
||||
lastZ = z;
|
||||
}
|
||||
|
||||
// 摇动触发
|
||||
function onShake() {
|
||||
shakeCount.value++;
|
||||
isShaking.value = true;
|
||||
|
||||
// 发送摇动计数到服务器
|
||||
const socket = connectionStore.getSocket();
|
||||
if (socket?.connected) {
|
||||
socket.emit(SOCKET_EVENTS.SHAKE_COUNT as any, { count: 1 });
|
||||
}
|
||||
|
||||
// 震动反馈
|
||||
if (navigator.vibrate) {
|
||||
navigator.vibrate(50);
|
||||
}
|
||||
|
||||
// 重置动画状态
|
||||
setTimeout(() => {
|
||||
isShaking.value = false;
|
||||
}, 200);
|
||||
}
|
||||
|
||||
// 返回投票页面
|
||||
function goBack() {
|
||||
router.push('/vote');
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!connectionStore.isConnected) {
|
||||
connectionStore.connect();
|
||||
}
|
||||
// 自动请求权限
|
||||
requestPermission();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
stopListening();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="shake-view">
|
||||
<!-- Header -->
|
||||
<header class="header">
|
||||
<button class="back-btn" @click="goBack">
|
||||
<span class="back-icon">←</span>
|
||||
返回
|
||||
</button>
|
||||
<h1 class="title">摇一摇</h1>
|
||||
<div class="placeholder"></div>
|
||||
</header>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="content">
|
||||
<!-- Permission Request -->
|
||||
<div v-if="!hasPermission && !permissionDenied" class="permission-request">
|
||||
<div class="permission-icon">📱</div>
|
||||
<p class="permission-text">需要获取设备运动权限才能使用摇一摇功能</p>
|
||||
<button class="permission-btn" @click="requestPermission">
|
||||
授权使用
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Permission Denied -->
|
||||
<div v-else-if="permissionDenied" class="permission-denied">
|
||||
<div class="denied-icon">🚫</div>
|
||||
<p class="denied-text">设备运动权限被拒绝</p>
|
||||
<p class="denied-hint">请按以下步骤开启权限:</p>
|
||||
<div class="denied-steps">
|
||||
<p>微信内:我 → 设置 → 通用 → 发现页管理 → 摇一摇</p>
|
||||
<p>或:iPhone 设置 → 微信 → 运动与健身</p>
|
||||
</div>
|
||||
<button class="retry-btn" @click="requestPermission">
|
||||
重新授权
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Shake Area -->
|
||||
<div v-else class="shake-area">
|
||||
<div class="shake-phone" :class="{ shaking: isShaking }">
|
||||
<div class="phone-screen">
|
||||
<span class="shake-emoji">🎉</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="shake-count-display">
|
||||
<span class="count-label">摇动次数</span>
|
||||
<span class="count-value">{{ shakeCount }}</span>
|
||||
</div>
|
||||
|
||||
<p class="shake-hint">摇动手机参与互动</p>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@use '../assets/styles/variables.scss' as *;
|
||||
|
||||
.shake-view {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #1a0a0a 0%, #2d1515 50%, #1a0a0a 100%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: $spacing-md;
|
||||
padding-top: calc(env(safe-area-inset-top) + #{$spacing-md});
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:active {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: $color-gold;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
width: 60px;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: $spacing-xl;
|
||||
}
|
||||
|
||||
// Permission Request
|
||||
.permission-request,
|
||||
.permission-denied {
|
||||
text-align: center;
|
||||
padding: $spacing-xl;
|
||||
}
|
||||
|
||||
.permission-icon,
|
||||
.denied-icon {
|
||||
font-size: 64px;
|
||||
margin-bottom: $spacing-lg;
|
||||
}
|
||||
|
||||
.permission-text,
|
||||
.denied-text {
|
||||
font-size: 16px;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
margin-bottom: $spacing-lg;
|
||||
}
|
||||
|
||||
.denied-hint {
|
||||
font-size: 14px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
margin-bottom: $spacing-md;
|
||||
}
|
||||
|
||||
.denied-steps {
|
||||
text-align: left;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
padding: $spacing-md;
|
||||
border-radius: 8px;
|
||||
margin-bottom: $spacing-lg;
|
||||
|
||||
p {
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
margin: $spacing-sm 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
.retry-btn {
|
||||
padding: 12px 24px;
|
||||
background: transparent;
|
||||
border: 1px solid $color-gold;
|
||||
border-radius: 20px;
|
||||
color: $color-gold;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:active {
|
||||
background: rgba($color-gold, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.permission-btn {
|
||||
padding: 14px 32px;
|
||||
background: linear-gradient(135deg, $color-gold 0%, #d4a84b 100%);
|
||||
border: none;
|
||||
border-radius: 24px;
|
||||
color: #1a0a0a;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
|
||||
&:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
}
|
||||
|
||||
// Shake Area
|
||||
.shake-area {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: $spacing-xl;
|
||||
}
|
||||
|
||||
.shake-phone {
|
||||
width: 120px;
|
||||
height: 200px;
|
||||
background: linear-gradient(145deg, #333 0%, #1a1a1a 100%);
|
||||
border-radius: 24px;
|
||||
padding: 8px;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
|
||||
transition: transform 0.1s;
|
||||
|
||||
&.shaking {
|
||||
animation: shake 0.2s ease-in-out;
|
||||
}
|
||||
}
|
||||
|
||||
.phone-screen {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(135deg, #2d1515 0%, #1a0a0a 100%);
|
||||
border-radius: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.shake-emoji {
|
||||
font-size: 48px;
|
||||
}
|
||||
|
||||
.shake-count-display {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.count-label {
|
||||
font-size: 14px;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.count-value {
|
||||
font-size: 64px;
|
||||
font-weight: 700;
|
||||
color: $color-gold;
|
||||
text-shadow: 0 0 20px rgba($color-gold, 0.5);
|
||||
}
|
||||
|
||||
.shake-hint {
|
||||
font-size: 14px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
@keyframes shake {
|
||||
0%, 100% { transform: translateX(0) rotate(0); }
|
||||
25% { transform: translateX(-10px) rotate(-5deg); }
|
||||
75% { transform: translateX(10px) rotate(5deg); }
|
||||
}
|
||||
</style>
|
||||
@@ -1,9 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, nextTick } from 'vue';
|
||||
import { ref, computed, onMounted, nextTick } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useVotingStore } from '../stores/voting';
|
||||
import { useConnectionStore } from '../stores/connection';
|
||||
import { showConfirmDialog } from 'vant';
|
||||
import { showConfirmDialog, showToast } from 'vant';
|
||||
import VotingDock from '../components/VotingDock.vue';
|
||||
import ProgramCard from '../components/ProgramCard.vue';
|
||||
import OnboardingTour from '../components/OnboardingTour.vue';
|
||||
@@ -43,6 +43,80 @@ const router = useRouter();
|
||||
const votingStore = useVotingStore();
|
||||
const connectionStore = useConnectionStore();
|
||||
|
||||
// 头像相关状态
|
||||
const userAvatar = ref<string | null>(null);
|
||||
const isLoadingAvatar = ref(false);
|
||||
|
||||
// 获取头像授权
|
||||
async function handleAvatarClick() {
|
||||
if (isLoadingAvatar.value) return;
|
||||
|
||||
// 如果已有头像,不做处理
|
||||
if (userAvatar.value) return;
|
||||
|
||||
isLoadingAvatar.value = true;
|
||||
try {
|
||||
// 获取 snsapi_userinfo 授权 URL
|
||||
const apiUrl = import.meta.env.VITE_API_URL || '';
|
||||
const currentUrl = window.location.href.split('?')[0];
|
||||
const res = await fetch(`${apiUrl}/api/mp/auth-url?scope=snsapi_userinfo&redirect_uri=${encodeURIComponent(currentUrl)}`);
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success && data.data?.authUrl) {
|
||||
// 保存 state 到 localStorage
|
||||
localStorage.setItem('avatar_auth_state', data.data.state);
|
||||
// 跳转到微信授权页面
|
||||
window.location.href = data.data.authUrl;
|
||||
} else {
|
||||
showToast('获取授权链接失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to get auth URL:', error);
|
||||
showToast('网络错误');
|
||||
} finally {
|
||||
isLoadingAvatar.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 处理授权回调
|
||||
async function handleAuthCallback() {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const code = urlParams.get('code');
|
||||
const state = urlParams.get('state');
|
||||
const savedState = localStorage.getItem('avatar_auth_state');
|
||||
|
||||
if (code && state && savedState === state) {
|
||||
localStorage.removeItem('avatar_auth_state');
|
||||
// 清除 URL 参数
|
||||
window.history.replaceState({}, '', window.location.pathname);
|
||||
|
||||
try {
|
||||
const apiUrl = import.meta.env.VITE_API_URL || '';
|
||||
const res = await fetch(`${apiUrl}/api/mp/upgrade-auth`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
code,
|
||||
state,
|
||||
userId: connectionStore.userId,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success && data.data?.avatar) {
|
||||
userAvatar.value = data.data.avatar;
|
||||
showToast('头像获取成功');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to upgrade auth:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 跳转到摇一摇页面
|
||||
function goToShake() {
|
||||
router.push('/shake');
|
||||
}
|
||||
|
||||
// Use server-synced programs from voting store
|
||||
const programs = computed(() => {
|
||||
// If no programs from server, show default list
|
||||
@@ -87,6 +161,8 @@ onMounted(() => {
|
||||
if (!connectionStore.isConnected) {
|
||||
connectionStore.connect();
|
||||
}
|
||||
// 处理头像授权回调
|
||||
handleAuthCallback();
|
||||
// Start onboarding tour if not completed
|
||||
if (shouldShowTour.value) {
|
||||
nextTick(() => {
|
||||
@@ -102,10 +178,19 @@ onMounted(() => {
|
||||
<div class="sticky-header">
|
||||
<!-- Header: 单行布局 -->
|
||||
<header class="page-header">
|
||||
<!-- 左侧:昵称 + 退出 -->
|
||||
<!-- 左侧:头像 + 昵称 + 退出 -->
|
||||
<div class="header-left">
|
||||
<span class="user-name">{{ connectionStore.userName || '访客' }}</span>
|
||||
<span class="logout-btn" @click="handleLogout">退出</span>
|
||||
<div class="avatar-wrapper" @click="handleAvatarClick">
|
||||
<img v-if="userAvatar" :src="userAvatar" class="user-avatar" alt="头像" />
|
||||
<div v-else class="avatar-placeholder" :class="{ loading: isLoadingAvatar }">
|
||||
<span v-if="!isLoadingAvatar">+</span>
|
||||
<span v-else class="loading-spinner"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="user-info">
|
||||
<span class="user-name">{{ connectionStore.userName || '访客' }}</span>
|
||||
<span class="logout-btn" @click="handleLogout">退出</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 中间:投票状态 -->
|
||||
<span class="voting-status" :class="{ active: votingStore.votingOpen }" data-tour="voting-status">
|
||||
@@ -144,6 +229,12 @@ onMounted(() => {
|
||||
<!-- Voting Dock -->
|
||||
<VotingDock />
|
||||
|
||||
<!-- 摇一摇悬浮按钮 (暂时隐藏) -->
|
||||
<!-- <button class="shake-fab" @click="goToShake">
|
||||
<span class="shake-icon">🎉</span>
|
||||
<span class="shake-label">摇一摇</span>
|
||||
</button> -->
|
||||
|
||||
<!-- Onboarding Tour -->
|
||||
<OnboardingTour
|
||||
:steps="tourSteps"
|
||||
@@ -183,10 +274,59 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.avatar-wrapper {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 2px solid $color-gold;
|
||||
}
|
||||
|
||||
.avatar-placeholder {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 2px dashed rgba(255, 255, 255, 0.3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
font-size: 18px;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:active {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
&.loading {
|
||||
border-color: $color-gold;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-top-color: $color-gold;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
.voting-status {
|
||||
@@ -273,6 +413,46 @@ onMounted(() => {
|
||||
gap: $spacing-lg;
|
||||
}
|
||||
|
||||
// 摇一摇悬浮按钮
|
||||
.shake-fab {
|
||||
position: fixed;
|
||||
right: 16px;
|
||||
bottom: 180px;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, $color-gold 0%, #d4a84b 100%);
|
||||
border: none;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
z-index: 100;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
|
||||
&:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
}
|
||||
|
||||
.shake-icon {
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.shake-label {
|
||||
font-size: 10px;
|
||||
color: #1a0a0a;
|
||||
font-weight: 600;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@keyframes pulse-dot {
|
||||
0%, 100% { opacity: 1; box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.4); }
|
||||
50% { opacity: 0.8; box-shadow: 0 0 0 4px rgba(34, 197, 94, 0); }
|
||||
|
||||
@@ -65,7 +65,8 @@ export default defineConfig({
|
||||
},
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 5174,
|
||||
port: 5173,
|
||||
allowedHosts: ['2026.cptp.let5see.xyz'],
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3000',
|
||||
|
||||
3
packages/client-screen/.env.development
Normal file
3
packages/client-screen/.env.development
Normal 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
|
||||
BIN
packages/client-screen/public/audio/horse.mp3
Normal file
BIN
packages/client-screen/public/audio/horse.mp3
Normal file
Binary file not shown.
@@ -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)
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
413
packages/client-screen/src/views/CheckinWallView.vue
Normal file
413
packages/client-screen/src/views/CheckinWallView.vue
Normal 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>
|
||||
@@ -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',
|
||||
|
||||
BIN
packages/server/public/avatars/wx_oECwX1HxV-47N4qC.jpg
Normal file
BIN
packages/server/public/avatars/wx_oECwX1HxV-47N4qC.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.7 KiB |
BIN
packages/server/public/avatars/wx_oECwX1KlFOgik0l_.jpg
Normal file
BIN
packages/server/public/avatars/wx_oECwX1KlFOgik0l_.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
@@ -1,4 +1,16 @@
|
||||
import 'dotenv/config';
|
||||
import dotenv from 'dotenv';
|
||||
import { resolve, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
// ES module compatible __dirname
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
// Load .env from server package directory
|
||||
const envPath = resolve(__dirname, '../../.env');
|
||||
console.log('[Config] Loading .env from:', envPath);
|
||||
const result = dotenv.config({ path: envPath });
|
||||
console.log('[Config] MOBILE_CLIENT_URL:', process.env.MOBILE_CLIENT_URL);
|
||||
|
||||
export const config = {
|
||||
// Server
|
||||
|
||||
82
packages/server/src/routes/checkin.routes.ts
Normal file
82
packages/server/src/routes/checkin.routes.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { redis } from '../config/redis';
|
||||
import { shakeService } from '../services/shake.service';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
const router = Router();
|
||||
|
||||
const CHECKIN_USERS_KEY = 'checkin:users';
|
||||
|
||||
/**
|
||||
* GET /api/checkin/users
|
||||
* 获取所有签到用户列表
|
||||
*/
|
||||
router.get('/users', async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const usersData = await redis.hgetall(CHECKIN_USERS_KEY);
|
||||
const shakeCounts = await shakeService.getAllShakeCounts();
|
||||
|
||||
const users = Object.entries(usersData).map(([id, data]) => {
|
||||
const user = JSON.parse(data);
|
||||
return {
|
||||
...user,
|
||||
shakeCount: shakeCounts[id] || 0,
|
||||
};
|
||||
});
|
||||
|
||||
// 按摇动次数降序排列
|
||||
users.sort((a, b) => b.shakeCount - a.shakeCount);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
users,
|
||||
totalCount: users.length,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error({ error }, 'Failed to get checkin users');
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: 'Internal server error',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/checkin/leaderboard
|
||||
* 获取摇一摇排行榜
|
||||
*/
|
||||
router.get('/leaderboard', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const limit = parseInt(req.query.limit as string) || 50;
|
||||
const leaderboard = await shakeService.getLeaderboard(limit);
|
||||
|
||||
// 补充用户信息
|
||||
const usersData = await redis.hgetall(CHECKIN_USERS_KEY);
|
||||
const enrichedLeaderboard = leaderboard.map((entry) => {
|
||||
const userData = usersData[entry.userId];
|
||||
const user = userData ? JSON.parse(userData) : { name: '未知用户' };
|
||||
return {
|
||||
...entry,
|
||||
userName: user.name,
|
||||
avatar: user.avatar,
|
||||
};
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
leaderboard: enrichedLeaderboard,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error({ error }, 'Failed to get shake leaderboard');
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: 'Internal server error',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Router, IRouter } from 'express';
|
||||
import { prizeConfigService } from '../services/prize-config.service';
|
||||
import { participantService } from '../services/participant.service';
|
||||
import { adminService } from '../services/admin.service';
|
||||
|
||||
const router: IRouter = Router();
|
||||
|
||||
@@ -41,5 +42,21 @@ router.get('/participants', (_req, res, next) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/public/lottery/results
|
||||
* Public read-only lottery results (for screen display)
|
||||
*/
|
||||
router.get('/lottery/results', async (_req, res, next) => {
|
||||
try {
|
||||
const results = await adminService.getLotteryResults();
|
||||
return res.json({
|
||||
success: true,
|
||||
data: results,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { wechatMpService } from '../services/wechat-mp.service';
|
||||
import { avatarService } from '../services/avatar.service';
|
||||
import { config } from '../config';
|
||||
import { logger } from '../utils/logger';
|
||||
import { redis } from '../config/redis';
|
||||
|
||||
const router = Router();
|
||||
|
||||
const CHECKIN_USERS_KEY = 'checkin:users';
|
||||
|
||||
/**
|
||||
* GET /api/mp/auth-url
|
||||
* 获取公众号网页授权URL
|
||||
@@ -145,4 +149,91 @@ function isRedirectAllowed(redirectUri: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/mp/upgrade-auth
|
||||
* 升级授权获取用户头像(snsapi_userinfo)
|
||||
*/
|
||||
router.post('/upgrade-auth', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { code, state, userId } = req.body;
|
||||
|
||||
if (!code || !state || !userId) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Missing required parameters',
|
||||
});
|
||||
}
|
||||
|
||||
if (!wechatMpService.isConfigured()) {
|
||||
return res.json({
|
||||
success: false,
|
||||
error: 'WeChat MP not configured',
|
||||
});
|
||||
}
|
||||
|
||||
const isValidState = await wechatMpService.validateState(state);
|
||||
if (!isValidState) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Invalid or expired state',
|
||||
});
|
||||
}
|
||||
|
||||
// 获取 access_token
|
||||
const tokenResult = await wechatMpService.getAccessToken(code);
|
||||
if (!tokenResult.success || !tokenResult.data) {
|
||||
return res.json({
|
||||
success: false,
|
||||
error: tokenResult.error || 'Failed to get access token',
|
||||
});
|
||||
}
|
||||
|
||||
// 获取用户信息(包含头像)
|
||||
const userInfo = await wechatMpService.getUserInfo(
|
||||
tokenResult.data.accessToken,
|
||||
tokenResult.data.openid
|
||||
);
|
||||
|
||||
if (!userInfo || !userInfo.headimgurl) {
|
||||
return res.json({
|
||||
success: false,
|
||||
error: 'Failed to get user info',
|
||||
});
|
||||
}
|
||||
|
||||
// 下载头像到本地
|
||||
const localAvatar = await avatarService.downloadAvatar(userId, userInfo.headimgurl);
|
||||
|
||||
if (!localAvatar) {
|
||||
return res.json({
|
||||
success: false,
|
||||
error: 'Failed to download avatar',
|
||||
});
|
||||
}
|
||||
|
||||
// 更新签到用户信息
|
||||
const existingData = await redis.hget(CHECKIN_USERS_KEY, userId);
|
||||
const userData = existingData ? JSON.parse(existingData) : { id: userId, name: userInfo.nickname };
|
||||
userData.avatar = localAvatar;
|
||||
userData.nickname = userInfo.nickname;
|
||||
await redis.hset(CHECKIN_USERS_KEY, userId, JSON.stringify(userData));
|
||||
|
||||
logger.info({ userId, avatar: localAvatar }, 'User avatar updated');
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
avatar: localAvatar,
|
||||
nickname: userInfo.nickname,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error({ error }, 'Failed to upgrade auth');
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: 'Internal server error',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -173,6 +173,22 @@ class AdminService extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Update event title
|
||||
*/
|
||||
async setEventTitle(title: string): Promise<{ success: boolean; message?: string }> {
|
||||
try {
|
||||
this.state.eventTitle = title;
|
||||
await this.saveState();
|
||||
logger.info({ title }, 'Event title updated');
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
logger.error({ error, title }, 'Failed to update event title');
|
||||
return { success: false, message: 'Failed to update event title' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Control voting state
|
||||
*/
|
||||
@@ -439,13 +455,13 @@ class AdminService extends EventEmitter {
|
||||
const winners = this.pickRandomWinners();
|
||||
this.state.lottery.subPhase = 'REVEAL';
|
||||
this.state.lottery.currentWinners = winners;
|
||||
|
||||
|
||||
// Persist winners to database
|
||||
await this.persistLotteryResults(winners);
|
||||
|
||||
// Auto-play fanfare music
|
||||
|
||||
// Auto-play reveal music - horse for round 4, fanfare for others
|
||||
this.state.music.isPlaying = true;
|
||||
this.state.music.track = 'fanfare';
|
||||
this.state.music.track = this.state.lottery.round === 4 ? 'horse' : 'fanfare';
|
||||
// After reveal animation, set to COMPLETE and stop music
|
||||
setTimeout(() => {
|
||||
this.state.lottery.subPhase = 'COMPLETE';
|
||||
@@ -580,7 +596,7 @@ class AdminService extends EventEmitter {
|
||||
const prizeLevel = this.state.lottery.prizeLevel;
|
||||
const prizeName = this.state.lottery.prizeName;
|
||||
const round = this.state.lottery.round;
|
||||
|
||||
|
||||
// Create draw results in database
|
||||
await prisma.drawResult.createMany({
|
||||
data: winners.map(winner => ({
|
||||
|
||||
75
packages/server/src/services/avatar.service.ts
Normal file
75
packages/server/src/services/avatar.service.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { createWriteStream, existsSync, mkdirSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { pipeline } from 'stream/promises';
|
||||
import { Readable } from 'stream';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
const AVATARS_DIR = join(process.cwd(), 'public', 'avatars');
|
||||
|
||||
/**
|
||||
* 头像下载和存储服务
|
||||
*/
|
||||
class AvatarService {
|
||||
constructor() {
|
||||
this.ensureAvatarsDir();
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保头像目录存在
|
||||
*/
|
||||
private ensureAvatarsDir(): void {
|
||||
if (!existsSync(AVATARS_DIR)) {
|
||||
mkdirSync(AVATARS_DIR, { recursive: true });
|
||||
logger.info({ dir: AVATARS_DIR }, 'Created avatars directory');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从微信 URL 下载头像并保存到本地
|
||||
*/
|
||||
async downloadAvatar(userId: string, avatarUrl: string): Promise<string | null> {
|
||||
try {
|
||||
if (!avatarUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 生成本地文件名
|
||||
const filename = `${userId}.jpg`;
|
||||
const filepath = join(AVATARS_DIR, filename);
|
||||
|
||||
// 下载头像
|
||||
const response = await fetch(avatarUrl);
|
||||
if (!response.ok) {
|
||||
logger.error({ userId, avatarUrl, status: response.status }, 'Failed to download avatar');
|
||||
return null;
|
||||
}
|
||||
|
||||
// 保存到本地
|
||||
const fileStream = createWriteStream(filepath);
|
||||
await pipeline(Readable.fromWeb(response.body as any), fileStream);
|
||||
|
||||
const localUrl = `/avatars/${filename}`;
|
||||
logger.info({ userId, localUrl }, 'Avatar downloaded successfully');
|
||||
|
||||
return localUrl;
|
||||
} catch (error) {
|
||||
logger.error({ error, userId, avatarUrl }, 'Failed to download avatar');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户头像本地路径
|
||||
*/
|
||||
getAvatarPath(userId: string): string | null {
|
||||
const filename = `${userId}.jpg`;
|
||||
const filepath = join(AVATARS_DIR, filename);
|
||||
|
||||
if (existsSync(filepath)) {
|
||||
return `/avatars/${filename}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export const avatarService = new AvatarService();
|
||||
@@ -38,8 +38,9 @@ class ScanLoginService {
|
||||
);
|
||||
|
||||
// Generate QR code URL (mobile client will access this)
|
||||
// Add timestamp to bypass WeChat WebView cache
|
||||
const baseUrl = config.mobileClientUrl || 'http://localhost:5174';
|
||||
const qrCodeUrl = `${baseUrl}/scan-login?token=${scanToken}`;
|
||||
const qrCodeUrl = `${baseUrl}/scan-login?token=${scanToken}&t=${Date.now()}`;
|
||||
|
||||
logger.info({ scanToken, pcSocketId }, 'Scan token generated');
|
||||
|
||||
|
||||
161
packages/server/src/services/shake.service.ts
Normal file
161
packages/server/src/services/shake.service.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { redis } from '../config/redis';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
const SHAKE_COUNTS_KEY = 'shake:counts';
|
||||
const SHAKE_LEADERBOARD_KEY = 'shake:leaderboard';
|
||||
const SHAKE_RATE_LIMIT_PREFIX = 'shake:rate:';
|
||||
const MAX_REQUESTS_PER_SECOND = 5;
|
||||
|
||||
/**
|
||||
* Lua 脚本:原子操作更新计数 + 限流检查
|
||||
* KEYS[1]: shake:counts (Hash)
|
||||
* KEYS[2]: shake:leaderboard (Sorted Set)
|
||||
* KEYS[3]: shake:rate:{userId} (限流 key)
|
||||
* ARGV[1]: userId
|
||||
* ARGV[2]: increment
|
||||
* ARGV[3]: maxPerSecond
|
||||
*
|
||||
* 返回: [newCount, currentRate] 或 [-1, currentRate] 表示被限流
|
||||
*/
|
||||
const SHAKE_LUA_SCRIPT = `
|
||||
local hashKey = KEYS[1]
|
||||
local sortedSetKey = KEYS[2]
|
||||
local rateLimitKey = KEYS[3]
|
||||
local userId = ARGV[1]
|
||||
local increment = tonumber(ARGV[2])
|
||||
local maxPerSecond = tonumber(ARGV[3])
|
||||
|
||||
-- 检查限流
|
||||
local currentRate = redis.call('INCR', rateLimitKey)
|
||||
if currentRate == 1 then
|
||||
redis.call('EXPIRE', rateLimitKey, 1)
|
||||
end
|
||||
if currentRate > maxPerSecond then
|
||||
return {-1, currentRate}
|
||||
end
|
||||
|
||||
-- 原子更新计数和排行榜
|
||||
local newCount = redis.call('HINCRBY', hashKey, userId, increment)
|
||||
redis.call('ZADD', sortedSetKey, newCount, userId)
|
||||
return {newCount, currentRate}
|
||||
`;
|
||||
|
||||
/**
|
||||
* 摇一摇计数服务
|
||||
* 使用 Redis Hash 存储每个用户的摇动次数
|
||||
*/
|
||||
class ShakeService {
|
||||
/**
|
||||
* 增加用户摇动次数(带限流)
|
||||
* @returns newCount 或 -1 表示被限流
|
||||
*/
|
||||
async incrementShakeCount(userId: string, increment: number = 1): Promise<number> {
|
||||
try {
|
||||
const rateLimitKey = `${SHAKE_RATE_LIMIT_PREFIX}${userId}`;
|
||||
const result = await redis.eval(
|
||||
SHAKE_LUA_SCRIPT,
|
||||
3,
|
||||
SHAKE_COUNTS_KEY,
|
||||
SHAKE_LEADERBOARD_KEY,
|
||||
rateLimitKey,
|
||||
userId,
|
||||
increment.toString(),
|
||||
MAX_REQUESTS_PER_SECOND.toString()
|
||||
) as [number, number];
|
||||
|
||||
const [newCount, currentRate] = result;
|
||||
|
||||
if (newCount === -1) {
|
||||
logger.debug({ userId, currentRate }, 'Shake rate limited');
|
||||
}
|
||||
|
||||
return newCount;
|
||||
} catch (error) {
|
||||
logger.error({ error, userId }, 'Failed to increment shake count');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户摇动次数
|
||||
*/
|
||||
async getShakeCount(userId: string): Promise<number> {
|
||||
try {
|
||||
const count = await redis.hget(SHAKE_COUNTS_KEY, userId);
|
||||
return count ? parseInt(count, 10) : 0;
|
||||
} catch (error) {
|
||||
logger.error({ error, userId }, 'Failed to get shake count');
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有用户的摇动次数
|
||||
*/
|
||||
async getAllShakeCounts(): Promise<Record<string, number>> {
|
||||
try {
|
||||
const counts = await redis.hgetall(SHAKE_COUNTS_KEY);
|
||||
const result: Record<string, number> = {};
|
||||
for (const [userId, count] of Object.entries(counts)) {
|
||||
result[userId] = parseInt(count, 10);
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error({ error }, 'Failed to get all shake counts');
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取摇动排行榜(按摇动次数降序)
|
||||
*/
|
||||
async getLeaderboard(limit: number = 100): Promise<Array<{ userId: string; shakeCount: number; rank: number }>> {
|
||||
try {
|
||||
// 使用 ZREVRANGE 获取降序排列的用户
|
||||
const results = await redis.zrevrange(SHAKE_LEADERBOARD_KEY, 0, limit - 1, 'WITHSCORES');
|
||||
const leaderboard: Array<{ userId: string; shakeCount: number; rank: number }> = [];
|
||||
|
||||
for (let i = 0; i < results.length; i += 2) {
|
||||
leaderboard.push({
|
||||
userId: results[i],
|
||||
shakeCount: parseInt(results[i + 1], 10),
|
||||
rank: Math.floor(i / 2) + 1,
|
||||
});
|
||||
}
|
||||
|
||||
return leaderboard;
|
||||
} catch (error) {
|
||||
logger.error({ error }, 'Failed to get shake leaderboard');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户排名
|
||||
*/
|
||||
async getUserRank(userId: string): Promise<number | null> {
|
||||
try {
|
||||
const rank = await redis.zrevrank(SHAKE_LEADERBOARD_KEY, userId);
|
||||
return rank !== null ? rank + 1 : null;
|
||||
} catch (error) {
|
||||
logger.error({ error, userId }, 'Failed to get user rank');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置所有摇动数据
|
||||
*/
|
||||
async resetAll(): Promise<void> {
|
||||
try {
|
||||
await redis.del(SHAKE_COUNTS_KEY);
|
||||
await redis.del(SHAKE_LEADERBOARD_KEY);
|
||||
logger.info('Shake data reset');
|
||||
} catch (error) {
|
||||
logger.error({ error }, 'Failed to reset shake data');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const shakeService = new ShakeService();
|
||||
@@ -29,6 +29,7 @@ export const SOCKET_EVENTS = {
|
||||
|
||||
// Admin control events
|
||||
ADMIN_PHASE_CHANGE: 'admin:phase_change',
|
||||
ADMIN_EVENT_TITLE_UPDATE: 'admin:event_title_update',
|
||||
ADMIN_VOTING_CONTROL: 'admin:voting_control',
|
||||
ADMIN_LOTTERY_CONTROL: 'admin:lottery_control',
|
||||
ADMIN_EMERGENCY_RESET: 'admin:emergency_reset',
|
||||
@@ -43,6 +44,19 @@ export const SOCKET_EVENTS = {
|
||||
// QR code display control events
|
||||
DISPLAY_SHOW_ENTRY_QR: 'display:show_entry_qr',
|
||||
DISPLAY_HIDE_QR: 'display:hide_qr',
|
||||
|
||||
// Checkin wall events
|
||||
CHECKIN_USER_JOIN: 'checkin:user_join',
|
||||
CHECKIN_USER_UPDATE: 'checkin:user_update',
|
||||
CHECKIN_WALL_SYNC: 'checkin:wall_sync',
|
||||
|
||||
// Shake events
|
||||
SHAKE_COUNT: 'shake:count',
|
||||
SHAKE_UPDATE: 'shake:update',
|
||||
SHAKE_LEADERBOARD: 'shake:leaderboard',
|
||||
|
||||
// Avatar events
|
||||
AVATAR_UPDATED: 'avatar:updated',
|
||||
} as const;
|
||||
|
||||
export const SOCKET_ROOMS = {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// System Phase State Machine
|
||||
// ============================================================================
|
||||
|
||||
export type SystemPhase = 'IDLE' | 'VOTING' | 'LOTTERY' | 'RESULTS' | 'LOTTERY_RESULTS';
|
||||
export type SystemPhase = 'IDLE' | 'VOTING' | 'LOTTERY' | 'RESULTS' | 'LOTTERY_RESULTS' | 'CHECKIN_WALL';
|
||||
|
||||
export type VotingSubPhase = 'CLOSED' | 'OPEN' | 'PAUSED';
|
||||
|
||||
@@ -23,6 +23,7 @@ export type LotteryRound = 1 | 2 | 3 | 4;
|
||||
|
||||
export interface AdminState {
|
||||
systemPhase: SystemPhase;
|
||||
eventTitle: string;
|
||||
voting: VotingState;
|
||||
lottery: LotteryState;
|
||||
music: MusicState;
|
||||
@@ -96,7 +97,7 @@ export interface LotteryWinner {
|
||||
|
||||
export interface MusicState {
|
||||
isPlaying: boolean;
|
||||
track: 'bgm' | 'lottery' | 'fanfare' | 'award' | 'none';
|
||||
track: 'bgm' | 'lottery' | 'fanfare' | 'award' | 'horse' | 'none';
|
||||
volume: number;
|
||||
}
|
||||
|
||||
@@ -174,6 +175,7 @@ export const DEFAULT_PROGRAMS: VotingProgram[] = [
|
||||
|
||||
export const INITIAL_ADMIN_STATE: AdminState = {
|
||||
systemPhase: 'IDLE',
|
||||
eventTitle: '公司2026年会',
|
||||
voting: {
|
||||
subPhase: 'CLOSED',
|
||||
totalVotes: 0,
|
||||
|
||||
75
packages/shared/src/types/checkin.types.ts
Normal file
75
packages/shared/src/types/checkin.types.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
// Checkin Wall & Shake Types
|
||||
|
||||
/**
|
||||
* 签到用户信息
|
||||
*/
|
||||
export interface CheckinUser {
|
||||
id: string;
|
||||
name: string;
|
||||
avatar?: string;
|
||||
shakeCount: number;
|
||||
checkinTime: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 签到墙同步数据
|
||||
*/
|
||||
export interface CheckinWallSyncPayload {
|
||||
users: CheckinUser[];
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户加入签到墙事件
|
||||
*/
|
||||
export interface CheckinUserJoinPayload {
|
||||
user: CheckinUser;
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户信息更新事件(头像、摇动次数等)
|
||||
*/
|
||||
export interface CheckinUserUpdatePayload {
|
||||
userId: string;
|
||||
avatar?: string;
|
||||
shakeCount?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 摇一摇计数事件
|
||||
*/
|
||||
export interface ShakeCountPayload {
|
||||
userId: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 摇一摇更新广播
|
||||
*/
|
||||
export interface ShakeUpdatePayload {
|
||||
userId: string;
|
||||
userName: string;
|
||||
avatar?: string;
|
||||
shakeCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 摇一摇排行榜
|
||||
*/
|
||||
export interface ShakeLeaderboardPayload {
|
||||
leaderboard: Array<{
|
||||
userId: string;
|
||||
userName: string;
|
||||
avatar?: string;
|
||||
shakeCount: number;
|
||||
rank: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 头像更新事件
|
||||
*/
|
||||
export interface AvatarUpdatedPayload {
|
||||
userId: string;
|
||||
avatar: string;
|
||||
}
|
||||
@@ -5,3 +5,4 @@ export * from './draw.types';
|
||||
export * from './admin.types';
|
||||
export * from './scan-login.types';
|
||||
export * from './wechat.types';
|
||||
export * from './checkin.types';
|
||||
|
||||
Reference in New Issue
Block a user