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,38 @@
{
email let5sne@gmail.com
}
2026.cptp.let5see.xyz {
# WeChat verification file
handle /A6e75lM1ge.txt {
respond "3a5d2d7f410fbcbc32e3c222b2cf28bf"
}
# Screen client → local dev server via tunnel
handle /screen* {
reverse_proxy localhost:5174
}
# API proxy → local backend via tunnel
handle /api/* {
header Cache-Control "no-store, no-cache, must-revalidate"
reverse_proxy localhost:3001
}
# WebSocket proxy → local backend via tunnel
handle /socket.io/* {
reverse_proxy localhost:3001
}
# Avatars proxy → local backend via tunnel
handle /avatars/* {
reverse_proxy localhost:3001
}
# Mobile client → local dev server via tunnel
handle {
reverse_proxy localhost:5173
}
encode gzip
}

View File

@@ -0,0 +1,48 @@
{
email let5sne@gmail.com
}
2026.cptp.let5see.xyz {
# WeChat verification file
handle /A6e75lM1ge.txt {
respond "3a5d2d7f410fbcbc32e3c222b2cf28bf"
}
# Screen client
handle /screen* {
root * /root/company-celebration/packages/client-screen/dist
uri strip_prefix /screen
try_files {path} /index.html
file_server
header Cache-Control "no-cache, no-store, must-revalidate"
@screenassets path_regexp assets \/assets\/.*
header @screenassets Cache-Control "public, max-age=31536000, immutable"
}
# API proxy
handle /api/* {
header Cache-Control "no-store, no-cache, must-revalidate"
reverse_proxy localhost:3000
}
# WebSocket proxy
handle /socket.io/* {
reverse_proxy localhost:3000
}
# Mobile client (default)
handle {
root * /root/company-celebration/packages/client-mobile/dist
try_files {path} /index.html
file_server
header Cache-Control "no-cache, no-store, must-revalidate"
@mobileassets path_regexp assets \/assets\/.*
header @mobileassets Cache-Control "public, max-age=31536000, immutable"
}
encode gzip
}

View File

@@ -0,0 +1,228 @@
# dev-tunnel - 本地开发隧道
通过 SSH 反向隧道将本地服务暴露到生产服务器,实现完整的本地开发调试(前端+后端),同时使用生产域名和微信授权。
## 使用场景
- 本地调试需要微信授权回调
- 本地调试需要使用生产域名
- 需要同时调试前端和后端
- 本地没有公网 IP
## 命令
```
/dev-tunnel start # 启动本地调试模式
/dev-tunnel stop # 恢复生产环境
/dev-tunnel status # 查看当前状态
```
## 架构说明
```
生产环境:
/api/*, /socket.io/* → localhost:3000 → PM2(gala-server)
/screen* → 静态文件 (client-screen/dist)
其他 → 静态文件 (client-mobile/dist)
调试模式:
/api/*, /socket.io/* → localhost:3001 → 隧道 → 本地:3000 (后端)
/screen* → localhost:5174 → 隧道 → 本地:5174 (大屏 dev)
其他 → localhost:5173 → 隧道 → 本地:5173 (移动端 dev)
```
## 隧道端口映射
| 服务 | 服务器端口 | 本地端口 |
|------|-----------|---------|
| 后端 API | 3001 | 3000 |
| 移动端前端 | 5173 | 5173 |
| 大屏端前端 | 5174 | 5174 |
## 操作步骤
### start - 启动调试模式
1. **服务器**: 停止 PM2 后端服务
2. **服务器**: 切换 Caddy 为调试模式配置
3. **本地**: 切换 MOBILE_CLIENT_URL 为生产域名(自动执行)
4. **本地**: 建立 SSH 隧道(自动执行)
5. **本地**: 启动后端和前端 dev server手动执行
### stop - 恢复生产环境
1. **本地**: 终止 SSH 隧道进程(自动执行)
2. **本地**: 恢复 MOBILE_CLIENT_URL 为本地地址(自动执行)
3. **服务器**: 恢复 Caddy 生产环境配置
4. **服务器**: 重启 PM2 后端服务
## 具体命令
### 启动调试模式
```bash
# 1. 停止服务器后端
ssh vote "pm2 stop gala-server"
# 2. 切换 Caddy 为调试模式配置
# 注意:需要先将 Caddyfile.debug 上传到服务器,或直接替换内容
ssh vote "cat > /etc/caddy/Caddyfile << 'EOF'
{
email let5sne@gmail.com
}
2026.cptp.let5see.xyz {
handle /A6e75lM1ge.txt {
respond \"3a5d2d7f410fbcbc32e3c222b2cf28bf\"
}
handle /screen* {
reverse_proxy localhost:5174
}
handle /api/* {
header Cache-Control \"no-store, no-cache, must-revalidate\"
reverse_proxy localhost:3001
}
handle /socket.io/* {
reverse_proxy localhost:3001
}
handle /avatars/* {
reverse_proxy localhost:3001
}
handle {
reverse_proxy localhost:5173
}
encode gzip
}
EOF"
# 3. 重载 Caddy
ssh vote "caddy reload --config /etc/caddy/Caddyfile"
# 4. 切换 MOBILE_CLIENT_URL 为生产域名
sed -i '' 's|MOBILE_CLIENT_URL=http://localhost:5173|MOBILE_CLIENT_URL=https://2026.cptp.let5see.xyz|g' packages/server/.env
# 5. 切换大屏端 VITE_MOBILE_URL 为生产域名
sed -i '' 's|VITE_MOBILE_URL=http://localhost:5173|VITE_MOBILE_URL=https://2026.cptp.let5see.xyz|g' packages/client-screen/.env.development
# 6. 切换移动端 VITE_SOCKET_URL/VITE_API_URL 为生产域名
sed -i '' 's|VITE_SOCKET_URL=http://localhost:3000|VITE_SOCKET_URL=https://2026.cptp.let5see.xyz|g' packages/client-mobile/.env.development
sed -i '' 's|VITE_API_URL=http://localhost:3000|VITE_API_URL=https://2026.cptp.let5see.xyz|g' packages/client-mobile/.env.development
# 7. 建立 SSH 隧道(自动后台运行)
ssh -R 3001:localhost:3000 -R 5173:localhost:5173 -R 5174:localhost:5174 vote -N -o ServerAliveInterval=30
# 6. 本地启动服务3个终端手动执行
# 终端1: 后端
pnpm --filter @gala/server dev
# 终端2: 移动端前端
pnpm --filter @gala/client-mobile dev --host
# 终端3: 大屏端前端
pnpm --filter @gala/client-screen dev --host
```
### 恢复生产环境
```bash
# 1. 终止本地 SSH 隧道进程
pkill -f "ssh -R 3001:localhost:3000"
# 2. 恢复 MOBILE_CLIENT_URL 为本地地址
sed -i '' 's|MOBILE_CLIENT_URL=https://2026.cptp.let5see.xyz|MOBILE_CLIENT_URL=http://localhost:5173|g' packages/server/.env
# 3. 恢复大屏端 VITE_MOBILE_URL 为本地地址
sed -i '' 's|VITE_MOBILE_URL=https://2026.cptp.let5see.xyz|VITE_MOBILE_URL=http://localhost:5173|g' packages/client-screen/.env.development
# 4. 恢复移动端 VITE_SOCKET_URL/VITE_API_URL 为本地地址
sed -i '' 's|VITE_SOCKET_URL=https://2026.cptp.let5see.xyz|VITE_SOCKET_URL=http://localhost:3000|g' packages/client-mobile/.env.development
sed -i '' 's|VITE_API_URL=https://2026.cptp.let5see.xyz|VITE_API_URL=http://localhost:3000|g' packages/client-mobile/.env.development
# 5. 恢复 Caddy 生产环境配置
ssh vote "cat > /etc/caddy/Caddyfile << 'EOF'
{
email let5sne@gmail.com
}
2026.cptp.let5see.xyz {
handle /A6e75lM1ge.txt {
respond \"3a5d2d7f410fbcbc32e3c222b2cf28bf\"
}
handle /screen* {
root * /root/company-celebration/packages/client-screen/dist
uri strip_prefix /screen
try_files {path} /index.html
file_server
header Cache-Control \"no-cache, no-store, must-revalidate\"
@screenassets path_regexp assets \\/assets\\/.*
header @screenassets Cache-Control \"public, max-age=31536000, immutable\"
}
handle /api/* {
header Cache-Control \"no-store, no-cache, must-revalidate\"
reverse_proxy localhost:3000
}
handle /socket.io/* {
reverse_proxy localhost:3000
}
handle {
root * /root/company-celebration/packages/client-mobile/dist
try_files {path} /index.html
file_server
header Cache-Control \"no-cache, no-store, must-revalidate\"
@mobileassets path_regexp assets \\/assets\\/.*
header @mobileassets Cache-Control \"public, max-age=31536000, immutable\"
}
encode gzip
}
EOF"
# 3. 重载 Caddy
ssh vote "caddy reload --config /etc/caddy/Caddyfile"
# 4. 重启服务器后端
ssh vote "pm2 restart gala-server"
```
## 前端 dev server 配置
### 必需配置
两个前端项目的 `vite.config.ts` 需要包含以下配置:
**client-mobile** (端口 5173):
```ts
server: {
host: '0.0.0.0',
port: 5173,
allowedHosts: ['2026.cptp.let5see.xyz'],
}
```
**client-screen** (端口 5174):
```ts
server: {
host: '0.0.0.0',
port: 5174,
allowedHosts: ['2026.cptp.let5see.xyz'],
}
```
### 配置说明
| 配置项 | 说明 |
|--------|------|
| `host: '0.0.0.0'` | 允许外部网络访问 |
| `allowedHosts` | 允许通过生产域名访问,否则 Vite 会拒绝请求 |
| `base: '/screen/'` | 大屏端需要配置,支持 `/screen` 路径前缀 |
## 注意事项
- SSH 隧道断开后,生产环境将不可用,务必及时恢复
- 调试完成后**必须执行 stop** 恢复生产环境
- 隧道命令需要保持终端运行,关闭终端会断开隧道
- 本地需要同时运行 4 个终端(后端 + 2个前端 + 隧道)
## 文件说明
- `Caddyfile.production` - 生产环境 Caddy 配置模板
- `Caddyfile.debug` - 调试模式 Caddy 配置模板

View File

@@ -101,6 +101,19 @@ VITE_API_URL=https://your-domain.com
---
## 本地调试
需要使用生产域名进行本地调试(如微信授权)时,使用 `/dev-tunnel` skill
```
/dev-tunnel start # 启动本地调试模式
/dev-tunnel stop # 恢复生产环境
```
详见 `.claude/skills/dev-tunnel/SKILL.md`
---
## 开发规范
### 类型定义

17
docker-compose.dev.yml Normal file
View File

@@ -0,0 +1,17 @@
version: '3.8'
services:
mysql:
image: mysql:8.0
container_name: gala-mysql-dev
restart: unless-stopped
ports:
- "3306:3306"
environment:
- MYSQL_ROOT_PASSWORD=ts123321qQ
- MYSQL_DATABASE=gala
volumes:
- mysql_dev_data:/var/lib/mysql
volumes:
mysql_dev_data:

View File

@@ -27,6 +27,7 @@
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-vue": "^9.32.0",
"prettier": "^3.4.2",
"tsx": "^4.19.2",
"typescript": "^5.7.3"
},
"engines": {

View File

@@ -0,0 +1,2 @@
VITE_SOCKET_URL=https://2026.cptp.let5see.xyz
VITE_API_URL=https://2026.cptp.let5see.xyz

View File

@@ -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" />

View File

@@ -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']

View File

@@ -28,6 +28,11 @@ const router = createRouter({
name: 'scan-login',
component: () => import('../views/ScanLoginView.vue'),
},
{
path: '/shake',
name: 'shake',
component: () => import('../views/ShakeView.vue'),
},
],
});

View File

@@ -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();

View 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>

View File

@@ -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); }

View File

@@ -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',

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',

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@@ -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

View 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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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 => ({

View 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();

View File

@@ -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');

View 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();

View File

@@ -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 = {

View File

@@ -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,

View 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;
}

View File

@@ -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';

6
pnpm-lock.yaml generated
View File

@@ -26,6 +26,9 @@ importers:
prettier:
specifier: ^3.4.2
version: 3.7.4
tsx:
specifier: ^4.19.2
version: 4.21.0
typescript:
specifier: ^5.7.3
version: 5.9.3
@@ -4205,11 +4208,12 @@ packages:
glob@11.1.0:
resolution: {integrity: sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==}
engines: {node: 20 || >=22}
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
hasBin: true
glob@7.2.3:
resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
deprecated: Glob versions prior to v9 are no longer supported
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
globals@13.24.0:
resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==}