Compare commits

..

30 Commits

Author SHA1 Message Date
empty
d22952a423 docs: add oauth security changelog 2026-02-04 01:34:26 +08:00
empty
c2731ce1dc fix: harden wechat mp oauth 2026-02-04 01:29:05 +08:00
empty
99fe68e851 chore: add award sound effect audio file 2026-02-04 00:38:35 +08:00
empty
83bc4da3cc fix(admin): add award to musicTrack type 2026-02-04 00:34:41 +08:00
empty
bd62dff06a feat(admin): add award sound effect button 2026-02-04 00:33:03 +08:00
empty
baacee50e6 fix(admin): show program name in single line with ellipsis 2026-02-04 00:04:09 +08:00
empty
69c789ba9d fix(admin): improve mobile layout for program config section 2026-02-04 00:01:51 +08:00
empty
0f484f63d4 docs: add CLAUDE.md with deployment guide 2026-02-04 00:00:25 +08:00
empty
307811886b fix(admin): add votes and stamps fields when creating new program 2026-02-03 23:55:27 +08:00
empty
92d560445e fix(admin): add performer and remark fields to programs type 2026-02-03 23:54:37 +08:00
empty
7fea6b8578 feat(admin): enhance program config with CRUD and reorder
- Change "结果展示" to "投票结果" in global controls
- Add program edit modal with name, team, performer, remark fields
- Add move up/down buttons for program reordering
- Add delete program with confirmation
- Add "添加节目" button for creating new programs
- Add responsive styles for mobile view

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 23:52:29 +08:00
empty
83bf1d3a43 chore: sync various improvements and fixes
- Update gitignore and serena config
- Improve connection and voting stores
- Enhance admin routes and socket handling
- Update client-screen views
- Add auth middleware

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 23:31:38 +08:00
empty
39caecdd95 fix(mobile): update progress ring hint text
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 23:29:42 +08:00
empty
19b7823d42 fix(mobile): add horizontal scroll hint to award selection step
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 23:22:01 +08:00
empty
a27134b82e feat(mobile): add voting status step to onboarding tour
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 23:20:03 +08:00
empty
7e15fcb377 fix(mobile): adjust onboarding tooltip width to prevent button wrapping
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 23:17:19 +08:00
empty
655f77ec3e fix(mobile): prevent onboarding tooltip from overflowing screen
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 23:12:44 +08:00
empty
65b153e5df feat(mobile): add onboarding tour for voting page
- Add OnboardingTour component with step-by-step guidance
- Add useOnboarding composable for state management
- Reset onboarding on logout for re-viewing
- 4 steps: welcome, award selection, program voting, progress

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 23:09:02 +08:00
empty
eee34916b0 fix(mobile): replace title with voting status in header
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 22:40:30 +08:00
empty
6ccc571be2 refactor(mobile): simplify header to single row layout
- Left: username + logout button (stacked)
- Center: page title
- Right: larger progress ring (56px)
- Remove connection status (shown in WeChat navbar)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 22:37:15 +08:00
empty
8ab575b14a feat(mobile): redesign vote page header layout
- Move username to top-left position
- Center the page title
- Move logout button to bottom-left
- Show connection status with latency on top-right

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 22:32:22 +08:00
empty
8f21ff6fd9 fix: add missing prisma utility file
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 22:29:02 +08:00
empty
406a5afa33 feat(server): add Program and Award database models
- Add Program and Award tables to Prisma schema
- Update program-config.service to support database with JSON fallback
- Update ProgramCard.vue display logic for dynamic teamName/performer
- Add seed script to import data from JSON config

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 22:26:47 +08:00
empty
b5fa4c7086 chore: add WeChat MP domain verification file
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 22:00:52 +08:00
empty
9f94f362d4 chore: add wechatMp config for MP OAuth
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 21:38:06 +08:00
empty
7a3b9a3694 chore: add missing auth utils and public routes
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 21:35:04 +08:00
empty
9b11f99fed feat: implement WeChat MP OAuth login
- Add wechat-mp.service.ts for MP web authorization
- Add wechat-mp.routes.ts with /api/mp endpoints
- Update EntryQRCode.vue to show H5 URL QR code
- Update HomeView.vue with WeChat auth detection

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 21:33:32 +08:00
empty
b53e732ffa docs: update documentation for WeChat OAuth login
- README.md: update scan login section to reflect WeChat OAuth flow
- DEPLOY.md: add WeChat environment variables configuration
- 联调测试方案.md: update test case A01 for new login flow

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 17:36:03 +08:00
empty
2cb9032187 feat: update login flow and add firework effects
- Update mobile HomeView to show WeChat scan login instructions
- Remove manual name/department input form from mobile client
- Add firework particle effects to big screen background
- Remove department field from login flow types

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 17:18:03 +08:00
a40c8b6045 Merge pull request 'feat: add WeChat environment variables to docker-compose' (#4) from feat/add-wechat-env-config into main
Reviewed-on: #4
2026-02-03 14:55:34 +08:00
56 changed files with 2709 additions and 413 deletions

View File

@@ -12,3 +12,9 @@ CORS_ORIGINS=https://your-domain.com,https://www.your-domain.com
# 你的域名(用于生成二维码等)
DOMAIN=your-domain.com
# 微信公众号网页授权配置
WECHAT_MP_APP_ID=your-mp-app-id
WECHAT_MP_APP_SECRET=your-mp-app-secret
# 允许的回调域名白名单逗号分隔host 级别)
WECHAT_MP_REDIRECT_ALLOWLIST=your-domain.com,www.your-domain.com

2
.gitignore vendored
View File

@@ -41,3 +41,5 @@ packages/server/prisma/*.db-journal
# Data files
参与抽奖人员名单.xlsx
测试清单.md
.codeartsdoer

View File

@@ -84,6 +84,27 @@ excluded_tools: []
# initial prompt for the project. It will always be given to the LLM upon activating the project
# (contrary to the memories, which are loaded on demand).
initial_prompt: ""
# the name by which the project can be referenced within Serena
project_name: "company-celebration2"
# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default)
included_optional_tools: []
# list of mode names to that are always to be included in the set of active modes
# The full set of modes to be activated is base_modes + default_modes.
# If the setting is undefined, the base_modes from the global configuration (serena_config.yml) apply.
# Otherwise, this setting overrides the global configuration.
# Set this to [] to disable base modes for this project.
# Set this to a list of mode names to always include the respective modes for this project.
base_modes:
# list of mode names that are to be activated by default.
# The full set of modes to be activated is base_modes + default_modes.
# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply.
# Otherwise, this overrides the setting from the global configuration (serena_config.yml).
# This setting can, in turn, be overridden by CLI parameters (--mode).
default_modes:
# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
# This cannot be combined with non-empty excluded_tools or included_optional_tools.
fixed_tools: []

99
CLAUDE.md Normal file
View File

@@ -0,0 +1,99 @@
# Company Celebration 项目指南
## 项目概述
年会庆典系统,包含投票、抽奖、大屏展示等功能。
## 技术栈
- **前端**: Vue 3 + TypeScript + Vite
- **后端**: Node.js + Express + Socket.IO
- **数据库**: MySQL + Redis
- **部署**: PM2
## 项目结构
```
packages/
├── client-mobile/ # 移动端(投票、扫码登录)
├── client-screen/ # 大屏端(展示、管理控制台)
├── server/ # 后端服务
└── shared/ # 共享类型和常量
```
---
## 运维部署流程
### 服务器信息
- **SSH 别名**: `vote`
- **项目目录**: `/root/company-celebration`
- **进程管理**: PM2 (`gala-server`)
### 标准部署流程
```bash
# 1. 本地提交并推送
git add <files>
git commit -m "commit message"
git push origin main
# 2. SSH 到服务器拉取代码
ssh vote "cd /root/company-celebration && git pull"
# 3. 构建项目
ssh vote "cd /root/company-celebration && pnpm build"
# 4. 重启服务
ssh vote "pm2 restart all && pm2 status"
```
### 常见问题处理
#### 服务器有本地修改冲突
```bash
ssh vote "cd /root/company-celebration && git stash && git pull"
```
#### 构建失败
1. 检查 TypeScript 类型错误
2. 本地修复后重新提交推送
3. 服务器重新拉取构建
#### 查看服务状态
```bash
ssh vote "pm2 status"
ssh vote "pm2 logs gala-server --lines 50"
```
---
## 开发规范
### 类型定义
- 共享类型定义在 `packages/shared/src/types/`
- 修改类型后需确保所有引用处同步更新
### 提交规范
使用 Conventional Commits:
- `feat`: 新功能
- `fix`: 修复
- `chore`: 杂项
- `refactor`: 重构
---
## 关键变更记录
### 2026-02-03
- 公众号 OAuth 安全加固:`/api/mp/auth-url` 生成并缓存 state`/api/mp/login` 强制校验 state。
- 增加回调域名白名单:新增 `WECHAT_MP_REDIRECT_ALLOWLIST`,仅允许白名单 host 的 `redirect_uri`
- 移动端授权回调携带 state微信回调时将 `state``code` 一起提交登录。
- 补充部署与生产环境变量示例:新增 `WECHAT_MP_APP_ID/SECRET/REDIRECT_ALLOWLIST` 说明。

View File

@@ -15,7 +15,36 @@
scp -r company-celebration2 user@your-server:/opt/
```
### 2. 配置域名
### 2. 配置环境变量
复制环境变量模板并配置:
```bash
cp .env.example .env
```
编辑 `.env` 文件,配置微信开放平台参数:
```env
# 微信开放平台配置(必填)
WECHAT_APP_ID=your_app_id
WECHAT_APP_SECRET=your_app_secret
WECHAT_REDIRECT_URI=https://your-domain.com/api/wechat/callback
```
> 注意:需要在微信开放平台配置授权回调域名(只填域名,不含路径)
如需启用公众号网页授权登录,请追加:
```env
# 微信公众号网页授权配置(可选)
WECHAT_MP_APP_ID=your-mp-app-id
WECHAT_MP_APP_SECRET=your-mp-app-secret
# 回调域名白名单host 级别,逗号分隔,不含协议与路径)
WECHAT_MP_REDIRECT_ALLOWLIST=your-domain.com,www.your-domain.com
```
### 3. 配置域名
编辑 `deploy/Caddyfile`,将 `your-domain.com` 替换为你的实际域名:
@@ -29,7 +58,7 @@ sed -i 's/your-domain.com/你的域名/g' deploy/Caddyfile
sed -i 's/your-email@example.com/你的邮箱/g' deploy/Caddyfile
```
### 3. 构建并启动
### 4. 构建并启动
```bash
docker-compose up -d --build
@@ -37,7 +66,7 @@ docker-compose up -d --build
Caddy 会自动申请和管理 SSL 证书,无需手动配置。
### 4. 查看日志
### 5. 查看日志
```bash
docker-compose logs -f

View File

@@ -0,0 +1 @@
ewdeSP35l53E3UEJ

View File

@@ -16,10 +16,10 @@
- Pixi.js 粒子动效(待机灯笼、滚动风暴、卷轴揭晓)
- 自动播放抽奖音乐与中奖音效
### 📱 扫码登录
- PC 端显示二维码
- 手机微信扫码后填写信息
- WebSocket 实时状态同步
### 📱 微信扫码登录
- 大屏显示微信开放平台授权二维码
- 用户微信扫码授权,自动获取 openid 完成登录
- WebSocket 实时状态同步,无需手动输入信息
### 🎛️ 导演控制台
- 全流程控制投票开关、抽奖控制、BGM 播放)
@@ -109,6 +109,16 @@ sed -i 's/your-domain.com/你的域名/g' deploy/Caddyfile
docker-compose up -d --build
```
### 公众号网页授权配置(可选)
若启用微信内 H5 授权登录,请在环境变量中配置:
```env
WECHAT_MP_APP_ID=your-mp-app-id
WECHAT_MP_APP_SECRET=your-mp-app-secret
WECHAT_MP_REDIRECT_ALLOWLIST=your-domain.com,www.your-domain.com
```
详细部署说明请参考 [DEPLOY.md](./DEPLOY.md)。
## 测试

View File

@@ -12,7 +12,6 @@ declare module 'vue' {
ProgramCard: typeof import('./components/ProgramCard.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
StampDock: typeof import('./components/StampDock.vue')['default']
VanButton: typeof import('vant/es')['Button']
VanCell: typeof import('vant/es')['Cell']
VanCellGroup: typeof import('vant/es')['CellGroup']

View File

@@ -0,0 +1,419 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue';
import type { TourStep } from '../composables/useOnboarding';
const props = defineProps<{
steps: TourStep[];
show: boolean;
currentStep: number;
}>();
const emit = defineEmits<{
next: [];
prev: [];
skip: [];
complete: [];
}>();
// Target element rect
const targetRect = ref<DOMRect | null>(null);
const tooltipRef = ref<HTMLElement | null>(null);
// Update target element position
function updateTargetRect() {
const step = props.steps[props.currentStep];
if (!step?.target) {
targetRect.value = null;
return;
}
const el = document.querySelector(step.target);
if (el) {
targetRect.value = el.getBoundingClientRect();
} else {
targetRect.value = null;
}
}
// Computed styles for highlight
const highlightStyle = computed(() => {
if (!targetRect.value) return {};
const padding = 8;
return {
top: `${targetRect.value.top - padding}px`,
left: `${targetRect.value.left - padding}px`,
width: `${targetRect.value.width + padding * 2}px`,
height: `${targetRect.value.height + padding * 2}px`,
};
});
// Tooltip position with boundary check
const tooltipStyle = computed(() => {
const step = props.steps[props.currentStep];
if (!step) return {};
// Center position (no target)
if (!targetRect.value) {
return {
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
};
}
const pos = step.position || 'bottom';
const rect = targetRect.value;
const gap = 16;
const margin = 16; // Screen edge margin
const tooltipWidth = 320;
const screenWidth = window.innerWidth;
// Calculate horizontal position with boundary check
function getHorizontalPosition() {
const centerX = rect.left + rect.width / 2;
let left = centerX - tooltipWidth / 2;
// Check left boundary
if (left < margin) {
left = margin;
}
// Check right boundary
if (left + tooltipWidth > screenWidth - margin) {
left = screenWidth - margin - tooltipWidth;
}
return `${left}px`;
}
switch (pos) {
case 'top':
return {
bottom: `${window.innerHeight - rect.top + gap}px`,
left: getHorizontalPosition(),
};
case 'bottom':
return {
top: `${rect.bottom + gap}px`,
left: getHorizontalPosition(),
};
case 'left':
return {
top: `${rect.top + rect.height / 2}px`,
right: `${screenWidth - rect.left + gap}px`,
transform: 'translateY(-50%)',
};
case 'right':
return {
top: `${rect.top + rect.height / 2}px`,
left: `${rect.right + gap}px`,
transform: 'translateY(-50%)',
};
default:
return {
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
};
}
});
// Arrow direction (opposite of tooltip position)
const arrowDirection = computed(() => {
const step = props.steps[props.currentStep];
if (!step?.target || !targetRect.value) return '';
return step.position || 'bottom';
});
// Current step data
const currentStepData = computed(() => props.steps[props.currentStep]);
const isLastStep = computed(() => props.currentStep === props.steps.length - 1);
const isFirstStep = computed(() => props.currentStep === 0);
// Button text
const nextButtonText = computed(() => {
if (isFirstStep.value) return '开始了解';
if (isLastStep.value) return '开始投票';
return '下一步';
});
function handleNext() {
if (isLastStep.value) {
emit('complete');
} else {
emit('next');
}
}
// Watch for step changes
watch(() => props.currentStep, () => {
nextTick(updateTargetRect);
});
watch(() => props.show, (val) => {
if (val) {
nextTick(updateTargetRect);
}
});
// Handle resize
onMounted(() => {
window.addEventListener('resize', updateTargetRect);
window.addEventListener('scroll', updateTargetRect, true);
if (props.show) {
nextTick(updateTargetRect);
}
});
onUnmounted(() => {
window.removeEventListener('resize', updateTargetRect);
window.removeEventListener('scroll', updateTargetRect, true);
});
</script>
<template>
<Teleport to="body">
<Transition name="fade">
<div v-if="show" class="onboarding-overlay">
<!-- Backdrop with hole -->
<div class="backdrop" :class="{ 'has-target': !!targetRect }">
<svg v-if="targetRect" class="backdrop-svg" width="100%" height="100%">
<defs>
<mask id="hole-mask">
<rect width="100%" height="100%" fill="white" />
<rect
:x="targetRect.left - 8"
:y="targetRect.top - 8"
:width="targetRect.width + 16"
:height="targetRect.height + 16"
rx="8"
fill="black"
/>
</mask>
</defs>
<rect width="100%" height="100%" fill="rgba(0,0,0,0.75)" mask="url(#hole-mask)" />
</svg>
<div v-else class="backdrop-solid" />
</div>
<!-- Highlight border -->
<div v-if="targetRect" class="highlight-border" :style="highlightStyle" />
<!-- Tooltip -->
<div ref="tooltipRef" class="tooltip" :style="tooltipStyle">
<!-- Arrow -->
<div v-if="arrowDirection" class="tooltip-arrow" :class="`arrow-${arrowDirection}`" />
<!-- Content -->
<div class="tooltip-content">
<h3 v-if="currentStepData?.title" class="tooltip-title">
{{ currentStepData.title }}
</h3>
<p class="tooltip-text">{{ currentStepData?.content }}</p>
</div>
<!-- Footer -->
<div class="tooltip-footer">
<div class="step-indicator">
<span
v-for="(_, idx) in steps"
:key="idx"
class="step-dot"
:class="{ active: idx === currentStep, completed: idx < currentStep }"
/>
</div>
<div class="tooltip-actions">
<button class="btn-skip" @click="$emit('skip')">跳过</button>
<button class="btn-next" @click="handleNext">
{{ nextButtonText }}
</button>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<style lang="scss" scoped>
@use '../assets/styles/variables.scss' as *;
.onboarding-overlay {
position: fixed;
inset: 0;
z-index: 9999;
pointer-events: auto;
}
.backdrop {
position: absolute;
inset: 0;
}
.backdrop-svg {
position: absolute;
inset: 0;
}
.backdrop-solid {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.75);
}
.highlight-border {
position: fixed;
border: 2px solid $color-gold;
border-radius: 8px;
box-shadow: 0 0 0 4px rgba($color-gold, 0.3);
pointer-events: none;
transition: all 0.3s ease;
}
.tooltip {
position: fixed;
min-width: 280px;
max-width: 320px;
background: #fff;
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
border: 2px solid $color-gold;
z-index: 10000;
overflow: hidden;
}
.tooltip-arrow {
position: absolute;
width: 12px;
height: 12px;
background: #fff;
border: 2px solid $color-gold;
transform: rotate(45deg);
&.arrow-top {
bottom: -8px;
left: 50%;
margin-left: -6px;
border-top: none;
border-left: none;
}
&.arrow-bottom {
top: -8px;
left: 50%;
margin-left: -6px;
border-bottom: none;
border-right: none;
}
&.arrow-left {
right: -8px;
top: 50%;
margin-top: -6px;
border-top: none;
border-left: none;
}
&.arrow-right {
left: -8px;
top: 50%;
margin-top: -6px;
border-bottom: none;
border-right: none;
}
}
.tooltip-content {
padding: 16px 16px 12px;
}
.tooltip-title {
margin: 0 0 8px;
font-size: 16px;
font-weight: 600;
color: $color-primary;
}
.tooltip-text {
margin: 0;
font-size: 14px;
line-height: 1.6;
color: $color-text-primary;
}
.tooltip-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: #fafafa;
border-top: 1px solid #eee;
}
.step-indicator {
display: flex;
gap: 6px;
}
.step-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #ddd;
transition: all 0.2s ease;
&.active {
background: $color-gold;
transform: scale(1.2);
}
&.completed {
background: $color-primary;
}
}
.tooltip-actions {
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
white-space: nowrap;
}
.btn-skip {
background: none;
border: none;
color: $color-text-muted;
font-size: 14px;
cursor: pointer;
padding: 4px 8px;
&:active {
color: $color-text-secondary;
}
}
.btn-next {
background: linear-gradient(135deg, $color-gold 0%, $color-gold-dark 100%);
border: none;
color: #1a1a1a;
font-size: 14px;
font-weight: 600;
padding: 8px 16px;
border-radius: 20px;
cursor: pointer;
box-shadow: 0 2px 8px rgba($color-gold, 0.4);
&:active {
transform: scale(0.96);
}
}
// Transitions
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

View File

@@ -60,12 +60,21 @@ const programNumber = computed(() => {
return num.toString().padStart(2, '0');
});
// From 显示:部门·表演者
// From 显示:根据数据动态显示
const fromDisplay = computed(() => {
if (props.performer) {
return `${props.teamName || ''}·${props.performer}`;
const team = props.teamName?.trim();
const performer = props.performer?.trim();
if (team && performer) {
return `${team}·${performer}`;
}
return props.teamName || 'The Performer';
if (performer) {
return performer;
}
if (team) {
return team;
}
return '表演者';
});
// 当前选中奖项的备注(用于移动端引导)

View File

@@ -31,7 +31,7 @@ function handleFrameClick(awardId: string) {
</script>
<template>
<div class="voting-dock">
<div class="voting-dock" data-tour="voting-dock">
<!-- 选中提示 -->
<div v-if="votingStore.selectedAward" class="selection-hint">
<span class="hint-icon">{{ votingStore.selectedAward.icon }}</span>

View File

@@ -0,0 +1,64 @@
import { ref, computed } from 'vue';
export interface TourStep {
target?: string;
title?: string;
content: string;
position?: 'top' | 'bottom' | 'left' | 'right' | 'center';
}
const STORAGE_KEY = 'gala_onboarding_completed';
// Shared state across components
const isCompleted = ref(localStorage.getItem(STORAGE_KEY) === 'true');
const showTour = ref(false);
const currentStep = ref(0);
export function useOnboarding() {
const shouldShowTour = computed(() => !isCompleted.value);
function start() {
if (isCompleted.value) return;
currentStep.value = 0;
showTour.value = true;
}
function next() {
currentStep.value++;
}
function prev() {
if (currentStep.value > 0) {
currentStep.value--;
}
}
function skip() {
complete();
}
function complete() {
showTour.value = false;
isCompleted.value = true;
localStorage.setItem(STORAGE_KEY, 'true');
}
function reset() {
localStorage.removeItem(STORAGE_KEY);
isCompleted.value = false;
currentStep.value = 0;
}
return {
isCompleted,
showTour,
currentStep,
shouldShowTour,
start,
next,
prev,
skip,
complete,
reset,
};
}

View File

@@ -18,7 +18,7 @@ type GalaSocket = Socket<ServerToClientEvents, ClientToServerEvents>;
const STORAGE_KEYS = {
USER_ID: 'gala_user_id',
USER_NAME: 'gala_user_name',
DEPARTMENT: 'gala_department',
SESSION_TOKEN: 'gala_session_token',
};
// Helper functions for localStorage
@@ -50,7 +50,7 @@ export const useConnectionStore = defineStore('connection', () => {
const reconnectAttempts = ref(0);
const userId = ref<string | null>(loadFromStorage(STORAGE_KEYS.USER_ID, null));
const userName = ref<string | null>(loadFromStorage(STORAGE_KEYS.USER_NAME, null));
const department = ref<string | null>(loadFromStorage(STORAGE_KEYS.DEPARTMENT, null));
const sessionToken = ref<string | null>(loadFromStorage(STORAGE_KEYS.SESSION_TOKEN, null));
const votedCategories = ref<VoteCategory[]>([]);
// Computed
@@ -85,6 +85,7 @@ export const useConnectionStore = defineStore('connection', () => {
reconnectionDelayMax: CONFIG.RECONNECTION_DELAY_MAX_MS,
timeout: CONFIG.HEARTBEAT_TIMEOUT_MS,
transports: ['websocket', 'polling'],
auth: sessionToken.value ? { token: sessionToken.value } : undefined,
});
// Connection events
@@ -193,8 +194,8 @@ export const useConnectionStore = defineStore('connection', () => {
{
userId: userId.value,
userName: userName.value || 'Guest',
department: department.value || '未知部门',
role: 'user',
sessionToken: sessionToken.value || undefined,
},
(response: any) => {
if (response.success) {
@@ -264,15 +265,17 @@ export const useConnectionStore = defineStore('connection', () => {
/**
* Set user info (and persist to localStorage)
*/
function setUser(id: string, name: string, dept: string) {
function setUser(id: string, name: string, token?: string) {
userId.value = id;
userName.value = name;
department.value = dept;
if (token) {
sessionToken.value = token;
saveToStorage(STORAGE_KEYS.SESSION_TOKEN, token);
}
// Persist to localStorage
saveToStorage(STORAGE_KEYS.USER_ID, id);
saveToStorage(STORAGE_KEYS.USER_NAME, name);
saveToStorage(STORAGE_KEYS.DEPARTMENT, dept);
// Rejoin if already connected
if (socket.value?.connected) {
@@ -287,13 +290,13 @@ export const useConnectionStore = defineStore('connection', () => {
// Clear state
userId.value = null;
userName.value = null;
department.value = null;
votedCategories.value = [];
sessionToken.value = null;
// Clear localStorage
localStorage.removeItem(STORAGE_KEYS.USER_ID);
localStorage.removeItem(STORAGE_KEYS.USER_NAME);
localStorage.removeItem(STORAGE_KEYS.DEPARTMENT);
localStorage.removeItem(STORAGE_KEYS.SESSION_TOKEN);
// Disconnect socket
disconnect();
@@ -333,7 +336,7 @@ export const useConnectionStore = defineStore('connection', () => {
reconnectAttempts,
userId,
userName,
department,
sessionToken,
votedCategories,
// Computed

View File

@@ -152,6 +152,10 @@ export const useVotingStore = defineStore('voting', () => {
showToast({ message: '请先选择一个奖项', position: 'bottom' });
return false;
}
if (!connectionStore.sessionToken) {
showToast({ message: '请先扫码登录', position: 'bottom' });
return false;
}
// 检查是否已为该节目投过票(任何奖项)
const existingAward = getProgramAward(programId);

View File

@@ -1,43 +1,123 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { useRouter, useRoute } from 'vue-router';
import { showLoadingToast, closeToast, showToast } from 'vant';
import { useConnectionStore } from '../stores/connection';
import { showToast } from 'vant';
const router = useRouter();
const route = useRoute();
const connectionStore = useConnectionStore();
const userName = ref('');
const userDept = ref('技术部');
const isLoading = ref(false);
const isProcessing = ref(false);
// Check if already logged in
onMounted(() => {
if (connectionStore.userId && connectionStore.userName && connectionStore.userName !== '访客') {
// Already logged in, redirect to vote page
router.replace('/vote');
// API base URL
const apiUrl = import.meta.env.VITE_API_URL || 'http://192.168.1.5:3000';
/**
* 检测是否在微信环境中
*/
function isWechatBrowser(): boolean {
const ua = navigator.userAgent.toLowerCase();
return ua.includes('micromessenger');
}
/**
* 用code完成登录
*/
async function loginWithCode(code: string, state?: string) {
isProcessing.value = true;
showLoadingToast({ message: '登录中...', forbidClick: true });
try {
const response = await fetch(`${apiUrl}/api/mp/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code, state }),
});
const result = await response.json();
closeToast();
if (result.success && result.data) {
// 设置用户信息
connectionStore.setUser(
result.data.userId,
result.data.userName,
result.data.sessionToken
);
showToast({ message: '登录成功!', type: 'success' });
// 跳转到投票页面
router.replace('/vote');
} else {
showToast({ message: result.error || '登录失败', type: 'fail' });
isProcessing.value = false;
}
} catch (err) {
closeToast();
showToast({ message: '网络错误,请重试', type: 'fail' });
isProcessing.value = false;
}
});
}
async function handleEnter() {
if (!userName.value.trim()) {
showToast('请输入您的姓名');
/**
* 跳转到微信授权页面
*/
async function redirectToWechatAuth() {
try {
// 获取授权URL
const currentUrl = window.location.href.split('?')[0]; // 移除已有的query参数
const response = await fetch(
`${apiUrl}/api/mp/auth-url?redirect_uri=${encodeURIComponent(currentUrl)}&scope=snsapi_base`
);
const result = await response.json();
if (result.success && result.data?.authUrl) {
// 跳转到微信授权页面
window.location.href = result.data.authUrl;
} else {
console.error('[HomeView] Failed to get auth URL:', result.error);
showToast({ message: '获取授权链接失败', type: 'fail' });
}
} catch (err) {
console.error('[HomeView] Failed to redirect to auth:', err);
showToast({ message: '网络错误', type: 'fail' });
}
}
/**
* 处理微信授权流程
*/
async function handleWechatAuth() {
// 检查URL中是否有code参数授权回调
const code = route.query.code as string;
const state = route.query.state as string;
if (code) {
// 有code用code完成登录
console.log('[HomeView] Got code from callback, logging in...');
await loginWithCode(code, state);
} else {
// 无code跳转到授权页面
console.log('[HomeView] No code, redirecting to auth...');
await redirectToWechatAuth();
}
}
// 页面加载时检查登录状态和微信环境
onMounted(async () => {
// 已登录则直接跳转
if (connectionStore.userId && connectionStore.userName && connectionStore.userName !== '访客') {
router.replace('/vote');
return;
}
isLoading.value = true;
// Generate a simple user ID (in production, this would come from auth)
const odrawId = `user_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
connectionStore.setUser(odrawId, userName.value.trim(), userDept.value);
// Wait for connection
await new Promise((resolve) => setTimeout(resolve, 500));
isLoading.value = false;
router.push('/vote');
}
// 在微信环境中自动处理授权
if (isWechatBrowser()) {
await handleWechatAuth();
}
});
</script>
<template>
@@ -59,40 +139,27 @@ async function handleEnter() {
<p class="subtitle">投票 · 抽奖 · 互动</p>
</div>
<div class="form-section">
<div class="input-wrapper guochao-border">
<van-field
v-model="userName"
placeholder="请输入您的姓名"
:border="false"
clearable
maxlength="20"
@keyup.enter="handleEnter"
/>
</div>
<div class="input-wrapper guochao-border">
<van-field
v-model="userDept"
placeholder="请输入您的部门"
:border="false"
clearable
maxlength="20"
@keyup.enter="handleEnter"
/>
<div class="scan-section">
<div class="scan-hint">
<van-icon name="scan" size="64" color="#c41230" />
<p class="hint-title">请使用微信扫码登录</p>
<p class="hint-desc">请前往大屏幕扫描二维码进入年会</p>
</div>
<van-button
class="enter-btn"
type="primary"
block
round
:loading="isLoading"
loading-text="进入中..."
@click="handleEnter"
>
进入年会
</van-button>
<div class="steps">
<div class="step">
<span class="step-num">1</span>
<span class="step-text">前往大屏幕</span>
</div>
<div class="step">
<span class="step-num">2</span>
<span class="step-text">微信扫描二维码</span>
</div>
<div class="step">
<span class="step-num">3</span>
<span class="step-text">授权后自动登录</span>
</div>
</div>
</div>
<!-- Features -->
@@ -240,31 +307,58 @@ async function handleEnter() {
}
}
.form-section {
.scan-section {
margin-bottom: $spacing-xl;
text-align: center;
.input-wrapper {
background: $color-bg-card;
margin-bottom: $spacing-md;
padding: $spacing-xs;
.scan-hint {
margin-bottom: $spacing-xl;
:deep(.van-field) {
background: transparent;
.hint-title {
font-size: $font-size-xl;
font-weight: bold;
color: $color-text-primary;
margin: $spacing-md 0 $spacing-xs;
}
.van-field__control {
text-align: center;
font-size: $font-size-lg;
}
.hint-desc {
font-size: $font-size-md;
color: $color-text-secondary;
}
}
.enter-btn {
background: linear-gradient(135deg, $color-primary-light 0%, $color-primary 100%);
border: none;
height: 48px;
font-size: $font-size-lg;
font-weight: 500;
box-shadow: $shadow-md;
.steps {
display: flex;
justify-content: center;
gap: $spacing-lg;
padding: $spacing-md;
background: rgba($color-primary, 0.05);
border-radius: $radius-lg;
}
.step {
display: flex;
flex-direction: column;
align-items: center;
gap: $spacing-xs;
}
.step-num {
width: 28px;
height: 28px;
border-radius: 50%;
background: $color-primary;
color: white;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
font-size: $font-size-sm;
}
.step-text {
font-size: $font-size-xs;
color: $color-text-secondary;
}
}

View File

@@ -14,7 +14,6 @@ const isValidating = ref<boolean>(true);
const isValid = ref<boolean>(false);
const isSubmitting = ref<boolean>(false);
const userName = ref<string>('');
const department = ref<string>('技术部');
const errorMessage = ref<string>('');
// API base URL - use LAN IP for mobile access
@@ -22,7 +21,7 @@ const apiUrl = import.meta.env.VITE_API_URL || 'http://192.168.1.5:3000';
// Computed
const canSubmit = computed(() => {
return userName.value.trim().length > 0 && department.value.trim().length > 0;
return userName.value.trim().length > 0;
});
// Validate token on mount
@@ -92,7 +91,6 @@ async function handleSubmit() {
body: JSON.stringify({
scanToken: token.value,
userName: userName.value.trim(),
department: department.value.trim(),
}),
});
@@ -101,8 +99,9 @@ async function handleSubmit() {
if (result.success) {
// Set user info in connection store
const userId = result.data?.sessionToken || `user_${Date.now()}`;
connectionStore.setUser(userId, userName.value.trim(), department.value.trim());
const userId = result.data?.userId || `user_${Date.now()}`;
const sessionToken = result.data?.sessionToken;
connectionStore.setUser(userId, userName.value.trim(), sessionToken);
showToast({ message: '登录成功!', type: 'success' });
@@ -156,13 +155,6 @@ async function handleSubmit() {
:rules="[{ required: true, message: '请输入姓名' }]"
maxlength="20"
/>
<van-field
v-model="department"
label="部门"
placeholder="请输入您的部门"
:rules="[{ required: true, message: '请输入部门' }]"
maxlength="20"
/>
</van-cell-group>
<div class="button-wrapper">

View File

@@ -1,11 +1,43 @@
<script setup lang="ts">
import { computed, onMounted } from 'vue';
import { 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 VotingDock from '../components/VotingDock.vue';
import ProgramCard from '../components/ProgramCard.vue';
import OnboardingTour from '../components/OnboardingTour.vue';
import { useOnboarding } from '../composables/useOnboarding';
// Onboarding
const { showTour, currentStep, shouldShowTour, start: startTour, next, skip, complete, reset: resetOnboarding } = useOnboarding();
const tourSteps = [
{
content: '欢迎参与节目投票!您有 7 张选票,可以为喜欢的节目投出不同奖项。',
position: 'center' as const,
},
{
target: '[data-tour="voting-status"]',
content: '请留意这里的投票状态,当显示"投票进行中"时才能开始投票。',
position: 'bottom' as const,
},
{
target: '[data-tour="voting-dock"]',
content: '第一步:选择一个奖项。每个奖项只能投给一个节目。可以横向滑动选择奖项。',
position: 'top' as const,
},
{
target: '[data-tour="program-card"]',
content: '第二步:点击节目卡片,将选中的奖项投给它。',
position: 'bottom' as const,
},
{
target: '[data-tour="progress-ring"]',
content: '这里显示您的投票进度,完成所有投票即可。',
position: 'bottom' as const,
},
];
const router = useRouter();
const votingStore = useVotingStore();
@@ -43,6 +75,7 @@ async function handleLogout() {
confirmButtonText: '确定',
cancelButtonText: '取消',
});
resetOnboarding(); // 重置引导状态,下次登录可再次查看
connectionStore.logout();
router.replace('/');
} catch {
@@ -54,6 +87,12 @@ onMounted(() => {
if (!connectionStore.isConnected) {
connectionStore.connect();
}
// Start onboarding tour if not completed
if (shouldShowTour.value) {
nextTick(() => {
setTimeout(startTour, 500);
});
}
});
</script>
@@ -61,22 +100,20 @@ onMounted(() => {
<div class="vote-view">
<!-- Sticky Header Container -->
<div class="sticky-header">
<!-- Header -->
<!-- Header: 单行布局 -->
<header class="page-header">
<!-- 左侧昵称 + 退出 -->
<div class="header-left">
<h1 class="page-title">节目投票</h1>
<div class="header-info">
<span class="user-name">{{ connectionStore.userName || '访客' }}</span>
<span class="info-divider">·</span>
<span class="status-indicator" :class="{ active: votingStore.votingOpen }">
<span class="status-dot" :class="{ pulsing: votingStore.votingOpen }"></span>
{{ votingStatusMessage }}
</span>
<span class="info-divider">·</span>
<span class="logout-btn" @click="handleLogout">退出</span>
</div>
<span class="user-name">{{ connectionStore.userName || '访客' }}</span>
<span class="logout-btn" @click="handleLogout">退出</span>
</div>
<div class="progress-ring">
<!-- 中间投票状态 -->
<span class="voting-status" :class="{ active: votingStore.votingOpen }" data-tour="voting-status">
<span class="status-dot" :class="{ pulsing: votingStore.votingOpen }"></span>
{{ votingStatusMessage }}
</span>
<!-- 右侧进度环 -->
<div class="progress-ring" data-tour="progress-ring">
<svg viewBox="0 0 36 36" class="circular-progress">
<path class="circle-bg" d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831" />
<path class="circle-progress" :stroke-dasharray="`${votingStore.totalTicketCount > 0 ? (votingStore.usedTicketCount / votingStore.totalTicketCount) * 100 : 0}, 100`"
@@ -100,11 +137,22 @@ onMounted(() => {
:index="index"
:status="program.status"
:is-current="program.id === votingStore.currentProgramId"
:data-tour="index === 0 ? 'program-card' : undefined"
/>
</main>
<!-- Voting Dock -->
<VotingDock />
<!-- Onboarding Tour -->
<OnboardingTour
:steps="tourSteps"
:show="showTour"
:current-step="currentStep"
@next="next"
@skip="skip"
@complete="complete"
/>
</div>
</template>
@@ -127,70 +175,39 @@ onMounted(() => {
background: $color-surface-glass;
backdrop-filter: $backdrop-blur;
-webkit-backdrop-filter: $backdrop-blur;
padding: $spacing-lg;
padding-top: calc(env(safe-area-inset-top) + #{$spacing-lg});
padding: $spacing-sm $spacing-md;
padding-top: calc(env(safe-area-inset-top) + #{$spacing-sm});
display: flex;
justify-content: space-between;
align-items: center;
justify-content: space-between;
}
.header-left {
display: flex;
flex-direction: column;
gap: 4px;
gap: 2px;
min-width: 60px;
}
.page-title {
font-size: $font-size-2xl;
font-weight: bold;
color: $color-text-inverse;
}
.user-name {
font-size: $font-size-sm;
color: $color-gold;
}
.header-info {
.voting-status {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
flex: 1;
font-size: $font-size-sm;
}
.info-divider {
color: rgba(255, 255, 255, 0.3);
}
.status-indicator {
display: flex;
align-items: center;
gap: 4px;
color: $color-text-secondary;
color: rgba(255, 255, 255, 0.6);
&.active {
color: #22c55e;
}
}
.logout-btn {
color: #999;
cursor: pointer;
padding: 2px 6px;
border-radius: 4px;
transition: all 0.2s;
&:active {
background: rgba(255, 255, 255, 0.1);
color: #fff;
}
}
.status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #666;
background: rgba(255, 255, 255, 0.4);
&.pulsing {
background: #22c55e;
@@ -198,10 +215,25 @@ onMounted(() => {
}
}
.user-name {
font-size: $font-size-sm;
color: $color-gold;
}
.logout-btn {
color: rgba(255, 255, 255, 0.6);
font-size: $font-size-xs;
cursor: pointer;
&:active {
color: #fff;
}
}
.progress-ring {
position: relative;
width: 48px;
height: 48px;
width: 56px;
height: 56px;
}
.circular-progress {

Binary file not shown.

View File

@@ -1,8 +1,6 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed } from 'vue';
import { ref, onMounted, onUnmounted } from 'vue';
import QRCode from 'qrcode';
import { useDisplayStore } from '../stores/display';
import type { WechatLoginSuccessPayload } from '@gala/shared/types';
const props = defineProps<{
mobileUrl: string;
@@ -10,59 +8,21 @@ const props = defineProps<{
const emit = defineEmits<{
close: [];
loginSuccess: [payload: WechatLoginSuccessPayload];
}>();
const displayStore = useDisplayStore();
const qrCodeDataUrl = ref<string>('');
const isLoading = ref(true);
const error = ref<string | null>(null);
const wechatAuthUrl = ref<string | null>(null);
const loginState = ref<string | null>(null);
const qrCodeDataUrl = ref<string>('');
// Use WeChat auth URL if available, otherwise fallback to mobile URL
const qrUrl = computed(() => wechatAuthUrl.value || props.mobileUrl);
// Fetch WeChat login URL from server
async function fetchWechatLoginUrl() {
// 生成移动端H5入口二维码
async function generateQRCode() {
isLoading.value = true;
error.value = null;
try {
const socket = displayStore.getSocket();
const socketId = socket?.id;
if (!socketId) {
console.warn('[EntryQRCode] Socket not connected, using fallback URL');
await generateQRCode(props.mobileUrl);
return;
}
const apiUrl = import.meta.env.VITE_API_URL || '';
const response = await fetch(`${apiUrl}/api/wechat/login?pcSocketId=${socketId}`);
const result = await response.json();
if (result.success && result.data) {
wechatAuthUrl.value = result.data.authUrl;
loginState.value = result.data.state;
await generateQRCode(result.data.authUrl);
console.log('[EntryQRCode] WeChat auth URL obtained');
} else {
console.warn('[EntryQRCode] WeChat not configured, using fallback URL');
await generateQRCode(props.mobileUrl);
}
} catch (err) {
console.error('[EntryQRCode] Failed to fetch WeChat login URL:', err);
// Fallback to mobile URL
await generateQRCode(props.mobileUrl);
}
}
// Generate QR code
async function generateQRCode(url: string) {
try {
qrCodeDataUrl.value = await QRCode.toDataURL(url, {
// 直接使用移动端URL生成二维码
// 用户扫码后在微信内打开H5H5会自动检测微信环境并跳转授权
qrCodeDataUrl.value = await QRCode.toDataURL(props.mobileUrl, {
width: 400,
margin: 2,
color: {
@@ -70,21 +30,15 @@ async function generateQRCode(url: string) {
light: '#ffffff',
},
});
isLoading.value = false;
console.log('[EntryQRCode] QR code generated for:', props.mobileUrl);
} catch (err) {
console.error('Failed to generate QR code:', err);
console.error('[EntryQRCode] Failed to generate QR code:', err);
error.value = '生成二维码失败';
} finally {
isLoading.value = false;
}
}
// Handle WeChat login success event
function handleWechatLoginSuccess(payload: WechatLoginSuccessPayload) {
console.log('[EntryQRCode] WeChat login success:', payload);
emit('loginSuccess', payload);
emit('close');
}
// Handle ESC key to close
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
@@ -92,30 +46,12 @@ function handleKeydown(e: KeyboardEvent) {
}
}
// Setup WebSocket listener for login success
function setupSocketListener() {
const socket = displayStore.getSocket();
if (socket) {
socket.on('wechat:login_success' as any, handleWechatLoginSuccess);
}
}
// Cleanup WebSocket listener
function cleanupSocketListener() {
const socket = displayStore.getSocket();
if (socket) {
socket.off('wechat:login_success' as any, handleWechatLoginSuccess);
}
}
onMounted(() => {
fetchWechatLoginUrl();
setupSocketListener();
generateQRCode();
window.addEventListener('keydown', handleKeydown);
});
onUnmounted(() => {
cleanupSocketListener();
window.removeEventListener('keydown', handleKeydown);
});
</script>
@@ -140,7 +76,7 @@ onUnmounted(() => {
</div>
<!-- WeChat logo hint -->
<div v-if="wechatAuthUrl" class="wechat-hint">
<div class="wechat-hint">
<svg class="wechat-icon" viewBox="0 0 24 24" fill="currentColor">
<path d="M8.691 2.188C3.891 2.188 0 5.476 0 9.53c0 2.212 1.17 4.203 3.002 5.55a.59.59 0 0 1 .213.665l-.39 1.48c-.019.07-.048.141-.048.213 0 .163.13.295.29.295a.326.326 0 0 0 .167-.054l1.903-1.114a.864.864 0 0 1 .717-.098 10.16 10.16 0 0 0 2.837.403c.276 0 .543-.027.811-.05-.857-2.578.157-4.972 1.932-6.446 1.703-1.415 3.882-1.98 5.853-1.838-.576-3.583-4.196-6.348-8.596-6.348zM5.785 5.991c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178A1.17 1.17 0 0 1 4.623 7.17c0-.651.52-1.18 1.162-1.18zm5.813 0c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178 1.17 1.17 0 0 1-1.162-1.178c0-.651.52-1.18 1.162-1.18zm5.34 2.867c-1.797-.052-3.746.512-5.28 1.786-1.72 1.428-2.687 3.72-1.78 6.22.942 2.453 3.666 4.229 6.884 4.229.826 0 1.622-.12 2.361-.336a.722.722 0 0 1 .598.082l1.584.926a.272.272 0 0 0 .14.047c.134 0 .24-.111.24-.247 0-.06-.023-.12-.038-.177l-.327-1.233a.582.582 0 0 1-.023-.156.49.49 0 0 1 .201-.398C23.024 18.48 24 16.82 24 14.98c0-3.21-2.931-5.837-6.656-6.088V8.89c-.135-.01-.269-.03-.406-.03zm-2.53 3.274c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983.976.976 0 0 1-.969-.983c0-.542.434-.982.97-.982zm4.844 0c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983.976.976 0 0 1-.969-.983c0-.542.434-.982.969-.982z"/>
</svg>
@@ -155,11 +91,11 @@ onUnmounted(() => {
</div>
<div class="step">
<span class="step-num">2</span>
<span class="step-text">{{ wechatAuthUrl ? '授权登录' : '填写姓名和部门' }}</span>
<span class="step-text">确认授权登录</span>
</div>
<div class="step">
<span class="step-num">3</span>
<span class="step-text">{{ wechatAuthUrl ? '自动进入年会' : '点击进入年会' }}</span>
<span class="step-text">自动进入年会</span>
</div>
</div>

View File

@@ -6,7 +6,7 @@ import { SOCKET_EVENTS } from '@gala/shared/constants';
import type { ScanStatusUpdatePayload, ScanLoginStatus } from '@gala/shared/types';
const emit = defineEmits<{
(e: 'login-success', data: { userId: string; userName: string; department: string; sessionToken: string }): void;
(e: 'login-success', data: { userId: string; userName: string; sessionToken: string }): void;
(e: 'login-cancel'): void;
}>();
@@ -125,7 +125,6 @@ function handleStatusUpdate(data: ScanStatusUpdatePayload) {
emit('login-success', {
userId: data.userInfo.userId,
userName: data.userInfo.userName,
department: data.userInfo.department,
sessionToken: data.userInfo.sessionToken,
});
}

View File

@@ -6,8 +6,22 @@ const COLORS = {
gold: 0xf0c239,
goldDark: 0xd4a84b,
goldLight: 0xffd700,
// Firework colors
fireworkRed: 0xff4444,
fireworkOrange: 0xff8844,
fireworkYellow: 0xffdd44,
fireworkPink: 0xff66aa,
fireworkWhite: 0xffffee,
};
// Firework color palettes
const FIREWORK_PALETTES = [
[COLORS.gold, COLORS.goldLight, COLORS.fireworkYellow],
[COLORS.fireworkRed, COLORS.fireworkOrange, COLORS.fireworkYellow],
[COLORS.fireworkPink, COLORS.fireworkWhite, COLORS.gold],
[COLORS.goldDark, COLORS.gold, COLORS.fireworkWhite],
];
interface Particle {
x: number;
y: number;
@@ -17,9 +31,11 @@ interface Particle {
alpha: number;
rotation: number;
rotationSpeed: number;
type: 'dust' | 'symbol' | 'streak';
type: 'dust' | 'symbol' | 'streak' | 'firework-trail' | 'firework-spark';
life: number;
maxLife: number;
color?: number;
gravity?: number;
}
export class BackgroundEffect {
@@ -29,6 +45,7 @@ export class BackgroundEffect {
private dustLayer: Graphics;
private symbolLayer: Graphics;
private streakLayer: Graphics;
private fireworkLayer: Graphics;
private time = 0;
private windOffset = 0;
private isDestroyed = false;
@@ -37,6 +54,7 @@ export class BackgroundEffect {
private readonly DUST_COUNT = 120;
private readonly SYMBOL_COUNT = 25;
private readonly STREAK_INTERVAL = 3000; // ms between streaks
private readonly FIREWORK_INTERVAL = 4000; // ms between fireworks
constructor() {
this.app = new Application();
@@ -44,6 +62,7 @@ export class BackgroundEffect {
this.dustLayer = new Graphics();
this.symbolLayer = new Graphics();
this.streakLayer = new Graphics();
this.fireworkLayer = new Graphics();
}
async init(canvas: HTMLCanvasElement): Promise<void> {
@@ -61,6 +80,7 @@ export class BackgroundEffect {
this.container.addChild(this.dustLayer);
this.container.addChild(this.symbolLayer);
this.container.addChild(this.streakLayer);
this.container.addChild(this.fireworkLayer);
this.app.stage.addChild(this.container);
// Draw vignette overlay
@@ -76,6 +96,9 @@ export class BackgroundEffect {
// Spawn streaks periodically
this.spawnStreakLoop();
// Spawn fireworks periodically
this.spawnFireworkLoop();
// Handle resize
window.addEventListener('resize', this.handleResize.bind(this));
}
@@ -157,6 +180,78 @@ export class BackgroundEffect {
setTimeout(() => this.spawnStreakLoop(), this.STREAK_INTERVAL + Math.random() * 2000);
}
private spawnFireworkLoop(): void {
if (this.isDestroyed) return;
this.launchFirework();
setTimeout(() => this.spawnFireworkLoop(), this.FIREWORK_INTERVAL + Math.random() * 3000);
}
private launchFirework(): void {
if (this.isDestroyed) return;
const w = this.app.screen.width;
const h = this.app.screen.height;
// Random launch position from bottom
const startX = w * 0.2 + Math.random() * w * 0.6;
const startY = h;
// Target explosion point
const targetY = h * 0.2 + Math.random() * h * 0.3;
// Calculate velocity to reach target
const flightTime = 60 + Math.random() * 30; // frames
const vy = -(startY - targetY) / flightTime - 0.5 * 0.15 * flightTime;
// Select color palette for this firework
const palette = FIREWORK_PALETTES[Math.floor(Math.random() * FIREWORK_PALETTES.length)];
// Create trail particle (the rising firework)
this.particles.push({
x: startX,
y: startY,
vx: (Math.random() - 0.5) * 0.5,
vy: vy,
size: 3,
alpha: 1,
rotation: 0,
rotationSpeed: 0,
type: 'firework-trail',
life: 0,
maxLife: flightTime,
color: palette[0],
gravity: 0.15,
});
}
private explodeFirework(x: number, y: number): void {
const palette = FIREWORK_PALETTES[Math.floor(Math.random() * FIREWORK_PALETTES.length)];
const sparkCount = 40 + Math.floor(Math.random() * 30);
for (let i = 0; i < sparkCount; i++) {
const angle = (Math.PI * 2 * i) / sparkCount + (Math.random() - 0.5) * 0.3;
const speed = 3 + Math.random() * 4;
const color = palette[Math.floor(Math.random() * palette.length)];
this.particles.push({
x: x,
y: y,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
size: 2 + Math.random() * 2,
alpha: 1,
rotation: 0,
rotationSpeed: 0,
type: 'firework-spark',
life: 0,
maxLife: 50 + Math.random() * 30,
color: color,
gravity: 0.08,
});
}
}
private update(ticker: Ticker): void {
if (this.isDestroyed) return;
@@ -167,6 +262,7 @@ export class BackgroundEffect {
this.dustLayer.clear();
this.symbolLayer.clear();
this.streakLayer.clear();
this.fireworkLayer.clear();
const w = this.app.screen.width;
const h = this.app.screen.height;
@@ -185,6 +281,25 @@ export class BackgroundEffect {
return false;
}
p.alpha = 0.9 * (1 - p.life / p.maxLife);
} else if (p.type === 'firework-trail') {
// Apply gravity
p.vy += p.gravity || 0.15;
if (p.life >= p.maxLife) {
// Explode at the end
this.explodeFirework(p.x, p.y);
return false;
}
p.alpha = 1;
} else if (p.type === 'firework-spark') {
// Apply gravity and friction
p.vy += p.gravity || 0.08;
p.vx *= 0.98;
p.vy *= 0.98;
if (p.life >= p.maxLife) {
return false;
}
p.alpha = 1 - (p.life / p.maxLife);
p.size *= 0.995;
} else if (p.type === 'symbol') {
// Reset symbols that go off top
if (p.y < -20) {
@@ -210,7 +325,9 @@ export class BackgroundEffect {
? this.dustLayer
: p.type === 'symbol'
? this.symbolLayer
: this.streakLayer;
: (p.type === 'firework-trail' || p.type === 'firework-spark')
? this.fireworkLayer
: this.streakLayer;
if (p.type === 'dust') {
// Simple gold circle
@@ -250,6 +367,35 @@ export class BackgroundEffect {
// Draw head
layer.circle(p.x, p.y, p.size);
layer.fill({ color: COLORS.goldLight, alpha: p.alpha });
} else if (p.type === 'firework-trail') {
// Rising firework with sparkle trail
const trailLength = 30;
for (let i = 0; i < 8; i++) {
const ratio = i / 8;
const tx = p.x + (Math.random() - 0.5) * 3;
const ty = p.y + ratio * trailLength;
const ta = p.alpha * (1 - ratio) * 0.6;
const ts = p.size * (1 - ratio * 0.5);
layer.circle(tx, ty, ts);
layer.fill({ color: p.color || COLORS.gold, alpha: ta });
}
// Bright head
layer.circle(p.x, p.y, p.size + 1);
layer.fill({ color: COLORS.fireworkWhite, alpha: p.alpha });
} else if (p.type === 'firework-spark') {
// Exploding spark with small trail
const trailLen = 5;
for (let i = 0; i < 3; i++) {
const ratio = i / 3;
const tx = p.x - p.vx * ratio * trailLen;
const ty = p.y - p.vy * ratio * trailLen;
const ta = p.alpha * (1 - ratio) * 0.5;
layer.circle(tx, ty, p.size * (1 - ratio * 0.3));
layer.fill({ color: p.color || COLORS.gold, alpha: ta });
}
// Spark head
layer.circle(p.x, p.y, p.size);
layer.fill({ color: p.color || COLORS.gold, alpha: p.alpha });
}
}

View File

@@ -2,24 +2,18 @@ import { createRouter, createWebHistory, type RouteLocationNormalized } from 'vu
// Admin auth constants
const ADMIN_TOKEN_KEY = 'gala_admin_token';
const ADMIN_ACCESS_CODE = '20268888';
// Auth guard for admin routes
function requireAdminAuth(to: RouteLocationNormalized) {
const token = localStorage.getItem(ADMIN_TOKEN_KEY);
if (!token || token !== generateToken(ADMIN_ACCESS_CODE)) {
if (!token) {
return { path: '/admin/login', query: { redirect: to.fullPath } };
}
return true;
}
// Simple token generator (not cryptographically secure, but sufficient for internal event)
function generateToken(code: string): string {
return btoa(`gala2026:${code}:${code.split('').reverse().join('')}`);
}
// Export for use in login component
export { ADMIN_TOKEN_KEY, ADMIN_ACCESS_CODE, generateToken };
export { ADMIN_TOKEN_KEY };
const router = createRouter({
history: createWebHistory('/screen/'),
@@ -49,7 +43,7 @@ const router = createRouter({
path: '/screen/results',
name: 'screen-results',
component: () => import('../views/VoteResultsView.vue'),
meta: { title: '年会大屏 - 投票结果' },
meta: { title: '年会大屏 - 结果展示' },
},
{
path: '/screen/lottery-results',

View File

@@ -14,6 +14,7 @@ import type {
} from '@gala/shared/types';
import { PRIZE_CONFIG } from '@gala/shared/types';
import { SOCKET_EVENTS } from '@gala/shared/constants';
import { ADMIN_TOKEN_KEY } from '../router';
type GalaSocket = Socket<ServerToClientEvents, ClientToServerEvents>;
@@ -33,7 +34,7 @@ export const useAdminStore = defineStore('admin', () => {
const votingOpen = ref(false);
const votingPaused = ref(false);
const totalVotes = ref(0);
const programs = ref<Array<{ id: string; name: string; teamName: string; order: number; status: string; votes: number; stamps: any[] }>>([]);
const programs = ref<Array<{ id: string; name: string; teamName: string; performer?: string; remark?: string; order: number; status: string; votes: number; stamps: any[] }>>([]);
const allowLateCatch = ref(true);
const currentProgramId = ref<string | null>(null);
const awards = ref<any[]>([]);
@@ -64,7 +65,7 @@ export const useAdminStore = defineStore('admin', () => {
// Music State
const musicPlaying = ref(false);
const musicTrack = ref<'bgm' | 'lottery' | 'fanfare' | 'none'>('none');
const musicTrack = ref<'bgm' | 'lottery' | 'fanfare' | 'award' | 'none'>('none');
// UI State
const pendingAction = ref<string | null>(null);
@@ -162,6 +163,8 @@ export const useAdminStore = defineStore('admin', () => {
isConnecting.value = true;
restoreState();
const adminToken = localStorage.getItem(ADMIN_TOKEN_KEY) || '';
const socketInstance = io(import.meta.env.VITE_SOCKET_URL || '', {
reconnection: true,
reconnectionAttempts: Infinity,
@@ -169,6 +172,7 @@ export const useAdminStore = defineStore('admin', () => {
reconnectionDelayMax: 5000,
timeout: 10000,
transports: ['websocket', 'polling'],
auth: adminToken ? { token: adminToken } : undefined,
});
socketInstance.on('connect', () => {
@@ -181,6 +185,7 @@ export const useAdminStore = defineStore('admin', () => {
userId: 'admin_main',
userName: 'Admin Console',
role: 'admin',
sessionToken: adminToken || undefined,
}, () => { });
// Request state sync

View File

@@ -22,6 +22,7 @@ const AUDIO_TRACKS: Record<string, string> = {
bgm: '/screen/audio/bgm.mp3',
lottery: '/screen/audio/lottery.mp3',
fanfare: '/screen/audio/fanfare.mp3',
award: '/screen/audio/award.mp3',
};
export const useDisplayStore = defineStore('display', () => {

View File

@@ -4,6 +4,7 @@ import { useRouter } from 'vue-router';
import { useAdminStore } from '../stores/admin';
import { PRIZE_CONFIG } from '@gala/shared/types';
import type { PrizeConfig, LotteryRound } from '@gala/shared/types';
import { ADMIN_TOKEN_KEY } from '../router';
// 简单的防抖函数
function debounce<T extends (...args: any[]) => void>(fn: T, delay: number = 300): (...args: Parameters<T>) => void {
@@ -35,6 +36,15 @@ function debounceLeading<T extends (...args: any[]) => void>(fn: T, delay: numbe
const router = useRouter();
const admin = useAdminStore();
const adminToken = () => localStorage.getItem(ADMIN_TOKEN_KEY) || '';
function getAdminHeaders(extra?: Record<string, string>) {
return {
'Content-Type': 'application/json',
'x-session-token': adminToken(),
...extra,
};
}
// Local UI state
const confirmResetCode = ref('');
@@ -67,6 +77,18 @@ function showToast(message: string, type: 'error' | 'success' | 'info' = 'info',
}, duration);
}
// Program editing state
const showProgramModal = ref(false);
const editingProgram = ref<any>(null);
const programForm = ref({
id: '',
name: '',
teamName: '',
performer: '',
remark: '',
});
const programSaving = ref(false);
// Prize configuration state
const showPrizeConfig = ref(false);
const prizeConfigLoading = ref(false);
@@ -89,7 +111,9 @@ async function readJsonSafe(res: Response): Promise<any> {
async function loadPrizeConfig() {
prizeConfigLoading.value = true;
try {
const res = await fetch('/api/admin/prizes');
const res = await fetch('/api/admin/prizes', {
headers: getAdminHeaders(),
});
const data = await readJsonSafe(res);
if (!res.ok) {
throw new Error(data?.error || data?.message || `加载奖项配置失败(${res.status})`);
@@ -112,7 +136,7 @@ async function savePrizeConfig() {
try {
const res = await fetch('/api/admin/prizes', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
headers: getAdminHeaders(),
body: JSON.stringify({ prizes: editingPrizes.value }),
});
const data = await readJsonSafe(res);
@@ -172,6 +196,7 @@ async function importParticipants() {
const response = await fetch('/api/admin/participants/import', {
method: 'POST',
headers: { 'x-session-token': adminToken() },
body: formData,
});
@@ -219,7 +244,9 @@ const tagLabels: Record<string, string> = {
// Load existing participants from server
async function loadParticipants() {
try {
const response = await fetch('/api/admin/participants');
const response = await fetch('/api/admin/participants', {
headers: getAdminHeaders(),
});
const data = await readJsonSafe(response);
if (!response.ok) {
throw new Error(data?.error || data?.message || `加载参与者失败(${response.status})`);
@@ -240,9 +267,134 @@ async function loadParticipants() {
}
}
// Navigation
function goBack() {
router.push('/');
// Program management functions
function openProgramEdit(program: any) {
editingProgram.value = program;
programForm.value = {
id: program.id,
name: program.name || '',
teamName: program.teamName || '',
performer: program.performer || '',
remark: program.remark || '',
};
showProgramModal.value = true;
}
function openAddProgram() {
editingProgram.value = null;
programForm.value = {
id: '',
name: '',
teamName: '',
performer: '',
remark: '',
};
showProgramModal.value = true;
}
function closeProgramModal() {
showProgramModal.value = false;
editingProgram.value = null;
}
async function saveProgramsToServer(programs: any[]) {
const res = await fetch('/api/admin/programs', {
method: 'PUT',
headers: getAdminHeaders(),
body: JSON.stringify({ programs }),
});
const data = await readJsonSafe(res);
if (!res.ok) {
throw new Error(data?.error || data?.message || `保存节目配置失败(${res.status})`);
}
return data;
}
async function saveProgram() {
programSaving.value = true;
try {
const programs = [...admin.programs];
if (editingProgram.value) {
// Edit existing program
const index = programs.findIndex(p => p.id === editingProgram.value.id);
if (index !== -1) {
programs[index] = {
...programs[index],
name: programForm.value.name,
teamName: programForm.value.teamName,
performer: programForm.value.performer,
remark: programForm.value.remark,
};
}
} else {
// Add new program
const newId = `program_${Date.now()}`;
programs.push({
id: newId,
name: programForm.value.name,
teamName: programForm.value.teamName,
performer: programForm.value.performer,
remark: programForm.value.remark,
status: 'pending',
order: programs.length + 1,
votes: 0,
stamps: [],
});
}
await saveProgramsToServer(programs);
showToast(editingProgram.value ? '节目已更新' : '节目已添加', 'success');
closeProgramModal();
} catch (e) {
const message = (e as Error).message;
showToast(`保存失败: ${message}`, 'error');
} finally {
programSaving.value = false;
}
}
async function deleteProgram(programId: string) {
if (!confirm('确定要删除这个节目吗?')) return;
try {
const programs = admin.programs.filter(p => p.id !== programId);
await saveProgramsToServer(programs);
showToast('节目已删除', 'success');
} catch (e) {
const message = (e as Error).message;
showToast(`删除失败: ${message}`, 'error');
}
}
async function moveProgram(programId: string, direction: 'up' | 'down') {
const programs = [...admin.programs];
const index = programs.findIndex(p => p.id === programId);
if (index === -1) return;
const newIndex = direction === 'up' ? index - 1 : index + 1;
if (newIndex < 0 || newIndex >= programs.length) return;
// Swap positions
[programs[index], programs[newIndex]] = [programs[newIndex], programs[index]];
// Update order field
programs.forEach((p, i) => {
p.order = i + 1;
});
try {
await saveProgramsToServer(programs);
showToast('顺序已调整', 'success');
} catch (e) {
const message = (e as Error).message;
showToast(`调整失败: ${message}`, 'error');
}
}
// Logout and go to login page
function handleLogout() {
localStorage.removeItem(ADMIN_TOKEN_KEY);
router.push('/admin/login');
}
// Phase control
@@ -331,7 +483,7 @@ async function redrawCurrentRound() {
try {
const res = await fetch('/api/admin/lottery/redraw', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: getAdminHeaders(),
});
const data = await res.json();
if (data.success) {
@@ -386,7 +538,7 @@ async function confirmAdvancedCleanup() {
try {
const res = await fetch('/api/admin/cleanup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: getAdminHeaders(),
body: JSON.stringify({
lottery: cleanupOptions.value.lottery,
voting: cleanupOptions.value.voting,
@@ -425,6 +577,10 @@ function playFanfare() {
admin.controlMusic('play', 'fanfare');
}
function playAward() {
admin.controlMusic('play', 'award');
}
// QR Code display control
function showEntryQR() {
const socket = admin.getSocket();
@@ -536,7 +692,7 @@ onMounted(() => {
<div class="admin-control">
<!-- Header -->
<header class="header">
<button class="back-btn" @click="goBack"> 返回</button>
<button class="back-btn" @click="handleLogout"> 退出</button>
<h1 class="title">管理控制台</h1>
<div class="status-bar">
<span class="status-item">
@@ -847,6 +1003,53 @@ onMounted(() => {
</div>
</details>
<!-- Section: Program Configuration -->
<details class="control-section program-section collapsible-section">
<summary class="section-header">
<h2>🎬 节目配置</h2>
<button class="add-program-btn" @click.stop="openAddProgram">+ 添加节目</button>
</summary>
<div class="section-body">
<div v-if="admin.programs.length === 0" class="no-stats">
暂无节目数据
</div>
<div v-else class="program-config-list">
<div v-for="(program, index) in admin.programs" :key="program.id" class="program-config-item">
<span class="program-order">{{ index + 1 }}</span>
<span class="program-name">{{ program.name }}</span>
<span class="program-team">{{ program.teamName }}</span>
<span class="program-status" :class="program.status">
{{ program.status === 'current' ? '进行中' : program.status === 'completed' ? '已完成' : '待演出' }}
</span>
<div class="program-actions">
<button
class="action-btn"
:disabled="index === 0"
@click="moveProgram(program.id, 'up')"
title="上移"
></button>
<button
class="action-btn"
:disabled="index === admin.programs.length - 1"
@click="moveProgram(program.id, 'down')"
title="下移"
></button>
<button
class="action-btn edit"
@click="openProgramEdit(program)"
title="编辑"
></button>
<button
class="action-btn delete"
@click="deleteProgram(program.id)"
title="删除"
>🗑</button>
</div>
</div>
</div>
</div>
</details>
<!-- Section C: Global Controls -->
<section class="control-section global-section">
<div class="section-header">
@@ -884,7 +1087,7 @@ onMounted(() => {
:class="{ active: admin.systemPhase === 'RESULTS' }"
@click="setPhase('RESULTS')"
>
结果展示
投票结果
</button>
<button
class="ctrl-btn"
@@ -923,6 +1126,9 @@ onMounted(() => {
<button class="ctrl-btn" @click="playFanfare">
播放礼花音效
</button>
<button class="ctrl-btn" @click="playAward">
播放颁奖音效
</button>
</div>
</div>
@@ -1006,7 +1212,7 @@ onMounted(() => {
<div class="modal cleanup-modal">
<h3>高级数据清理</h3>
<p>选择要清理的数据类型和存储层</p>
<div class="cleanup-sections">
<!-- Lottery Cleanup Options -->
<div class="cleanup-section">
@@ -1065,6 +1271,44 @@ onMounted(() => {
</div>
</div>
</div>
<!-- Program Edit Modal -->
<div v-if="showProgramModal" class="modal-overlay" @click.self="closeProgramModal">
<div class="modal program-modal">
<div class="modal-header">
<h3>{{ editingProgram ? '编辑节目' : '添加节目' }}</h3>
<button class="close-btn" @click="closeProgramModal">×</button>
</div>
<div class="modal-body">
<div class="form-group">
<label>节目名称</label>
<input v-model="programForm.name" type="text" placeholder="请输入节目名称" />
</div>
<div class="form-group">
<label>团队名称</label>
<input v-model="programForm.teamName" type="text" placeholder="请输入团队名称" />
</div>
<div class="form-group">
<label>表演者</label>
<input v-model="programForm.performer" type="text" placeholder="请输入表演者" />
</div>
<div class="form-group">
<label>备注</label>
<input v-model="programForm.remark" type="text" placeholder="请输入备注(可选)" />
</div>
</div>
<div class="modal-actions">
<button class="ctrl-btn outline" @click="closeProgramModal">取消</button>
<button
class="ctrl-btn primary"
:disabled="!programForm.name || programSaving"
@click="saveProgram"
>
{{ programSaving ? '保存中...' : '保存' }}
</button>
</div>
</div>
</div>
</div>
</template>
@@ -2075,11 +2319,56 @@ $admin-danger: #ef4444;
.control-section {
&.lottery-section,
&.global-section,
&.stats-section {
&.stats-section,
&.program-section {
grid-column: span 1;
}
}
// Program Section Mobile
.program-config-item {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
padding: 12px;
.program-team {
display: none;
}
.program-status {
font-size: 11px;
padding: 3px 8px;
margin-left: auto;
}
}
.program-order {
width: 28px;
height: 28px;
font-size: 12px;
flex-shrink: 0;
}
.program-name {
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.program-actions {
width: 100%;
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 4px;
padding-top: 8px;
border-top: 1px solid rgba(255, 255, 255, 0.05);
}
// Stats Section Mobile
.award-stats-grid {
grid-template-columns: 1fr;
@@ -2469,4 +2758,199 @@ $admin-danger: #ef4444;
color: #888;
padding: 20px;
}
// Program Configuration Styles
.program-section {
grid-column: span 2;
}
.add-program-btn {
padding: 6px 12px;
background: $admin-primary;
border: none;
border-radius: 6px;
color: white;
font-size: 12px;
cursor: pointer;
transition: all 0.2s;
&:hover {
background: lighten($admin-primary, 5%);
}
}
.program-config-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.program-config-item {
display: grid;
grid-template-columns: 40px 1fr 120px 80px auto;
gap: 12px;
align-items: center;
padding: 12px 16px;
background: rgba(255, 255, 255, 0.02);
border: 1px solid $admin-border;
border-radius: 8px;
transition: background 0.2s;
&:hover {
background: rgba(255, 255, 255, 0.04);
}
}
.program-order {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
background: rgba($color-gold, 0.2);
color: $color-gold;
border-radius: 6px;
font-size: 13px;
font-weight: 600;
}
.program-name {
font-size: 14px;
font-weight: 500;
color: $admin-text;
}
.program-team {
font-size: 13px;
color: $admin-text-muted;
}
.program-status {
padding: 4px 10px;
border-radius: 4px;
font-size: 12px;
text-align: center;
&.pending {
background: rgba(#888, 0.2);
color: #888;
}
&.current {
background: rgba($admin-warning, 0.2);
color: $admin-warning;
}
&.completed {
background: rgba($admin-success, 0.2);
color: $admin-success;
}
}
.program-actions {
display: flex;
gap: 6px;
}
.action-btn {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
background: #252525;
border: 1px solid $admin-border;
border-radius: 6px;
color: $admin-text-muted;
font-size: 12px;
cursor: pointer;
transition: all 0.2s;
&:hover:not(:disabled) {
background: #333;
color: $admin-text;
}
&:disabled {
opacity: 0.3;
cursor: not-allowed;
}
&.edit:hover:not(:disabled) {
border-color: $admin-primary;
color: $admin-primary;
}
&.delete:hover:not(:disabled) {
border-color: $admin-danger;
color: $admin-danger;
}
}
// Program Modal Styles
.program-modal {
width: 450px;
max-width: 90vw;
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
h3 {
margin: 0;
font-size: 18px;
}
.close-btn {
background: none;
border: none;
color: $admin-text-muted;
font-size: 24px;
cursor: pointer;
padding: 0;
line-height: 1;
&:hover {
color: $admin-text;
}
}
}
.modal-body {
display: flex;
flex-direction: column;
gap: 16px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 6px;
label {
font-size: 13px;
color: $admin-text-muted;
}
input {
padding: 10px 12px;
background: #252525;
border: 1px solid $admin-border;
border-radius: 6px;
color: $admin-text;
font-size: 14px;
&:focus {
outline: none;
border-color: $admin-primary;
}
&::placeholder {
color: #555;
}
}
}
}
</style>

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { ADMIN_TOKEN_KEY, ADMIN_ACCESS_CODE, generateToken } from '../router';
import { ADMIN_TOKEN_KEY } from '../router';
const router = useRouter();
const route = useRoute();
@@ -20,22 +20,29 @@ async function handleLogin() {
isLoading.value = true;
// Simulate network delay for UX
await new Promise(resolve => setTimeout(resolve, 500));
try {
const response = await fetch('/api/admin/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ accessCode: accessCode.value.trim() }),
});
const result = await response.json();
if (accessCode.value === ADMIN_ACCESS_CODE) {
// Save token to localStorage
localStorage.setItem(ADMIN_TOKEN_KEY, generateToken(ADMIN_ACCESS_CODE));
if (!response.ok || !result.success) {
error.value = result.error || '访问码错误';
accessCode.value = '';
return;
}
localStorage.setItem(ADMIN_TOKEN_KEY, result.data.sessionToken);
// Redirect to console or original destination
const redirect = route.query.redirect as string || '/admin/director-console';
router.push(redirect);
} else {
error.value = '访问码错误';
accessCode.value = '';
} catch (e) {
error.value = '网络错误,请重试';
} finally {
isLoading.value = false;
}
isLoading.value = false;
}
function handleKeydown(e: KeyboardEvent) {

View File

@@ -22,7 +22,9 @@ function goBack() {
// Fetch lottery results from API
async function fetchLotteryResults() {
try {
const res = await fetch('/api/admin/lottery/results');
const res = await fetch('/api/admin/lottery/results', {
headers: { 'x-session-token': localStorage.getItem('gala_admin_token') || '' },
});
const data = await res.json();
if (data.success && data.data?.draws) {
lotteryResults.value = data.data.draws;

View File

@@ -30,7 +30,7 @@ const prizes = ref<Array<{ level: string; name: string; winnerCount: number; poo
// 从 API 获取奖项配置
async function fetchPrizes() {
try {
const response = await fetch('/api/admin/prizes');
const response = await fetch('/api/public/prizes');
const data = await response.json();
if (data.success && data.data?.prizes) {
prizes.value = data.data.prizes;
@@ -53,7 +53,7 @@ let realParticipants: Participant[] = [];
async function fetchParticipants() {
try {
isLoading.value = true;
const response = await fetch('/api/admin/participants');
const response = await fetch('/api/public/participants');
const data = await response.json();
if (data.success && data.data?.participants) {

View File

@@ -13,7 +13,7 @@ const showQRLogin = ref(false);
// Mobile URL for entry QR code - use environment variable or fallback to current origin
const mobileUrl = import.meta.env.VITE_MOBILE_URL || window.location.origin;
function handleLoginSuccess(data: { userId: string; userName: string; department: string; sessionToken: string }) {
function handleLoginSuccess(data: { userId: string; userName: string; sessionToken: string }) {
console.log('Login success:', data);
showQRLogin.value = false;
// 可以在这里处理登录成功后的逻辑

View File

@@ -81,7 +81,7 @@ onMounted(() => {
<!-- Header -->
<header class="header">
<button class="back-btn" @click="goBack"> 返回</button>
<h1 class="title gold-text">投票结果</h1>
<h1 class="title gold-text">结果展示</h1>
<div class="status-indicator">
<span class="dot" :class="admin.isConnected ? 'online' : 'offline'"></span>
{{ admin.isConnected ? '已连接' : '连接中...' }}

View File

@@ -146,7 +146,6 @@ scenarios:
userId: "{{ userId }}"
userName: "压测用户-{{ userId }}"
role: "user"
department: "测试部门"
- think: 0.5
- loop:
- function: "selectRandomProgram"
@@ -176,7 +175,6 @@ scenarios:
userId: "{{ userId }}"
userName: "速投用户-{{ userId }}"
role: "user"
department: "压力测试"
- loop:
- function: "selectRandomProgram"
- function: "selectRandomTicketType"
@@ -204,5 +202,4 @@ scenarios:
userId: "{{ userId }}"
userName: "观望用户-{{ userId }}"
role: "user"
department: "观望组"
- think: 8 # 长时间停留

View File

@@ -81,7 +81,6 @@ scenarios:
userId: "{{ userId }}"
userName: "快投-{{ userId }}"
role: "user"
department: "快速组"
# 快速投7票每票间隔 0.2-0.4 秒)
- loop:
- function: "selectSequentialTicket"
@@ -108,7 +107,6 @@ scenarios:
userId: "{{ userId }}"
userName: "正常-{{ userId }}"
role: "user"
department: "正常组"
# 正常速度投7票每票间隔 1-2 秒,看节目、思考)
- loop:
- function: "selectSequentialTicket"
@@ -136,7 +134,6 @@ scenarios:
userId: "{{ userId }}"
userName: "慢投-{{ userId }}"
role: "user"
department: "慢速组"
# 先观望 5-10 秒
- function: "randomLongDelay"
- think: "{{ longDelay }}"

View File

@@ -11,7 +11,7 @@
"db:generate": "prisma generate",
"db:migrate": "prisma migrate dev",
"db:push": "prisma db push",
"db:seed": "tsx src/scripts/seed.ts",
"db:seed": "tsx prisma/seed.ts",
"test": "vitest",
"test:load": "artillery run load-test/vote-load-test.yaml -e standard",
"test:load:smoke": "artillery run load-test/vote-load-test.yaml -e smoke",

View File

@@ -11,7 +11,6 @@ datasource db {
model User {
id String @id @default(cuid())
name String @db.VarChar(100)
department String @db.VarChar(100)
avatar String? @db.VarChar(512)
birthYear Int? @map("birth_year")
zodiac String? @db.VarChar(20)
@@ -93,6 +92,37 @@ model DrawResult {
@@map("draw_results")
}
// Programs for voting
model Program {
id String @id @default(cuid())
name String @db.VarChar(100)
teamName String @default("") @map("team_name") @db.VarChar(100)
performer String @default("") @db.VarChar(200)
order Int @default(0)
remark String? @db.Text
isActive Boolean @default(true) @map("is_active")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@index([order])
@@map("programs")
}
// Awards for voting
model Award {
id String @id @default(cuid())
name String @db.VarChar(100)
icon String @db.VarChar(20)
order Int @default(0)
remark String? @db.Text
isActive Boolean @default(true) @map("is_active")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@index([order])
@@map("awards")
}
// Draw sessions
model DrawSession {
id String @id @default(cuid())

View File

@@ -0,0 +1,110 @@
/**
* Prisma Seed Script
* Populates initial program and award data from JSON config
*/
import { PrismaClient } from '@prisma/client';
import * as fs from 'fs';
import * as path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const prisma = new PrismaClient();
interface ProgramData {
id: string;
name: string;
teamName: string;
performer: string;
order: number;
remark: string;
}
interface AwardData {
id: string;
name: string;
icon: string;
order: number;
remark: string;
}
interface ConfigFile {
programs: ProgramData[];
awards: AwardData[];
}
async function main() {
console.log('Starting seed...');
// Load config from JSON file
const configPath = path.join(__dirname, '../config/programs.json');
if (!fs.existsSync(configPath)) {
console.error('Config file not found:', configPath);
process.exit(1);
}
const config: ConfigFile = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
// Seed programs
console.log(`Seeding ${config.programs.length} programs...`);
for (const p of config.programs) {
await prisma.program.upsert({
where: { id: p.id },
create: {
id: p.id,
name: p.name,
teamName: p.teamName || '',
performer: p.performer || '',
order: p.order,
remark: p.remark || null,
isActive: true,
},
update: {
name: p.name,
teamName: p.teamName || '',
performer: p.performer || '',
order: p.order,
remark: p.remark || null,
isActive: true,
},
});
console.log(` - ${p.name}`);
}
// Seed awards
console.log(`Seeding ${config.awards.length} awards...`);
for (const a of config.awards) {
await prisma.award.upsert({
where: { id: a.id },
create: {
id: a.id,
name: a.name,
icon: a.icon,
order: a.order,
remark: a.remark || null,
isActive: true,
},
update: {
name: a.name,
icon: a.icon,
order: a.order,
remark: a.remark || null,
isActive: true,
},
});
console.log(` - ${a.name}`);
}
console.log('Seed completed!');
}
main()
.catch((e) => {
console.error('Seed failed:', e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

View File

@@ -2,6 +2,9 @@ import express, { Application } from 'express';
import cors from 'cors';
import helmet from 'helmet';
import compression from 'compression';
import path from 'path';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
import { config } from './config';
import { logger } from './utils/logger';
import { errorHandler } from './middleware/errorHandler';
@@ -10,6 +13,12 @@ import voteRoutes from './routes/vote.routes';
import adminRoutes from './routes/admin.routes';
import scanRoutes from './routes/scan.routes';
import wechatRoutes from './routes/wechat.routes';
import wechatMpRoutes from './routes/wechat-mp.routes';
import publicRoutes from './routes/public.routes';
// ES Module __dirname equivalent
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
export const app: Application = express();
@@ -51,6 +60,9 @@ app.use(express.urlencoded({ extended: true }));
// Request logging
app.use(requestLogger);
// Static files (for WeChat domain verification, etc.)
app.use(express.static(path.join(__dirname, '../public')));
// Health check
app.get('/health', (_req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
@@ -61,6 +73,8 @@ app.use('/api/vote', voteRoutes);
app.use('/api/admin', adminRoutes);
app.use('/api/scan', scanRoutes);
app.use('/api/wechat', wechatRoutes);
app.use('/api/mp', wechatMpRoutes);
app.use('/api/public', publicRoutes);
// 404 handler
app.use((_req, res) => {

View File

@@ -28,6 +28,8 @@ export const config = {
// JWT (for session tokens)
jwtSecret: process.env.JWT_SECRET || 'dev-secret-change-in-production',
jwtExpiresIn: process.env.JWT_EXPIRES_IN || '24h',
adminAccessCode: process.env.ADMIN_ACCESS_CODE || '',
adminTokenTtlSeconds: parseInt(process.env.ADMIN_TOKEN_TTL || '43200', 10), // 12 hours
// Mobile client URL (for QR code generation)
mobileClientUrl: process.env.MOBILE_CLIENT_URL || 'http://192.168.1.5:5174',
@@ -51,12 +53,22 @@ export const config = {
intervalMs: 1000,
},
// WeChat Open Platform
// WeChat Open Platform (PC扫码登录暂不使用)
wechat: {
appId: process.env.WECHAT_APP_ID || '',
appSecret: process.env.WECHAT_APP_SECRET || '',
redirectUri: process.env.WECHAT_REDIRECT_URI || '',
},
// WeChat MP (公众号网页授权)
wechatMp: {
appId: process.env.WECHAT_MP_APP_ID || '',
appSecret: process.env.WECHAT_MP_APP_SECRET || '',
redirectAllowlist: (process.env.WECHAT_MP_REDIRECT_ALLOWLIST || '')
.split(',')
.map((item) => item.trim())
.filter(Boolean),
},
} as const;
export type Config = typeof config;

View File

@@ -12,6 +12,13 @@ import { participantService } from './services/participant.service';
async function main(): Promise<void> {
try {
if (!config.adminAccessCode) {
logger.warn('ADMIN_ACCESS_CODE 未配置,管理员登录将被拒绝');
}
if (!config.isDev && config.jwtSecret === 'dev-secret-change-in-production') {
logger.error('生产环境禁止使用默认 JWT_SECRET请配置环境变量');
process.exit(1);
}
// Connect to Database
logger.info('Connecting to Database...');
await connectDatabase();
@@ -79,4 +86,3 @@ async function main(): Promise<void> {
}
main();

View File

@@ -0,0 +1,64 @@
import type { Request, Response, NextFunction } from 'express';
import { extractBearerToken, verifySessionToken } from '../utils/auth';
export interface AuthenticatedRequest extends Request {
user?: {
userId: string;
userName?: string;
role: 'user' | 'admin' | 'screen';
token: string;
};
}
function getTokenFromRequest(req: Request): string | null {
const bearer = extractBearerToken(req.headers.authorization);
if (bearer) return bearer;
const headerToken = req.headers['x-session-token'];
if (typeof headerToken === 'string' && headerToken.trim()) {
return headerToken.trim();
}
return null;
}
export async function requireAuth(
req: AuthenticatedRequest,
res: Response,
next: NextFunction
): Promise<void> {
const token = getTokenFromRequest(req);
if (!token) {
res.status(401).json({ success: false, error: 'UNAUTHORIZED', message: 'Missing session token' });
return;
}
const user = await verifySessionToken(token);
if (!user) {
res.status(401).json({ success: false, error: 'UNAUTHORIZED', message: 'Invalid session token' });
return;
}
req.user = user;
next();
}
export async function requireAdmin(
req: AuthenticatedRequest,
res: Response,
next: NextFunction
): Promise<void> {
const token = getTokenFromRequest(req);
if (!token) {
res.status(401).json({ success: false, error: 'UNAUTHORIZED', message: 'Missing session token' });
return;
}
const user = await verifySessionToken(token);
if (!user || user.role !== 'admin') {
res.status(403).json({ success: false, error: 'FORBIDDEN', message: 'Admin access required' });
return;
}
req.user = user;
next();
}

View File

@@ -4,10 +4,50 @@ import { participantService } from '../services/participant.service';
import { prizeConfigService } from '../services/prize-config.service';
import { programConfigService } from '../services/program-config.service';
import { adminService } from '../services/admin.service';
import { requireAdmin } from '../middleware/auth';
import { createSessionToken } from '../utils/auth';
import { config } from '../config';
const router: IRouter = Router();
const upload = multer({ storage: multer.memoryStorage() });
/**
* POST /api/admin/login
* Admin login to obtain session token
*/
router.post('/login', async (req, res, next) => {
try {
const { accessCode } = req.body as { accessCode?: string };
if (!accessCode) {
return res.status(400).json({ success: false, error: '访问码不能为空' });
}
if (!config.adminAccessCode) {
return res.status(500).json({ success: false, error: '服务端未配置访问码' });
}
if (accessCode !== config.adminAccessCode) {
return res.status(401).json({ success: false, error: '访问码错误' });
}
const sessionToken = await createSessionToken(
{ userId: 'admin_main', userName: 'Admin Console', role: 'admin' },
config.adminTokenTtlSeconds
);
return res.json({
success: true,
data: {
sessionToken,
expiresIn: config.adminTokenTtlSeconds,
},
});
} catch (error) {
next(error);
}
});
// Admin auth guard (applies to all routes below)
router.use(requireAdmin);
/**
* GET /api/admin/stats
* Get system statistics
@@ -296,4 +336,3 @@ router.post('/cleanup', async (req, res, next) => {
});
export default router;

View File

@@ -0,0 +1,45 @@
import { Router, IRouter } from 'express';
import { prizeConfigService } from '../services/prize-config.service';
import { participantService } from '../services/participant.service';
const router: IRouter = Router();
/**
* GET /api/public/prizes
* Public read-only prize configuration (for screen display)
*/
router.get('/prizes', (_req, res, next) => {
try {
const config = prizeConfigService.getFullConfig();
return res.json({
success: true,
data: config,
});
} catch (error) {
next(error);
}
});
/**
* GET /api/public/participants
* Public read-only participant list (for screen display)
*/
router.get('/participants', (_req, res, next) => {
try {
const participants = participantService.getAll();
const stats = participantService.getStats();
return res.json({
success: true,
data: {
count: participants.length,
tagDistribution: stats.tagDistribution,
participants,
},
});
} catch (error) {
next(error);
}
});
export default router;

View File

@@ -97,19 +97,18 @@ router.post('/scanned', async (req, res, next) => {
*/
router.post('/confirm', async (req, res, next) => {
try {
const { scanToken, userName, department } = req.body;
const { scanToken, userName } = req.body;
if (!scanToken || !userName || !department) {
if (!scanToken || !userName) {
return res.status(400).json({
success: false,
error: 'scanToken, userName, and department are required',
error: 'scanToken and userName are required',
});
}
const result = await scanLoginService.confirmLogin(
scanToken,
userName,
department
userName
);
if (result.success && result.data) {
@@ -121,7 +120,6 @@ router.post('/confirm', async (req, res, next) => {
userInfo: {
userId: result.data.userId,
userName,
department,
sessionToken: result.data.sessionToken,
},
};

View File

@@ -3,6 +3,7 @@ import { voteService } from '../services/vote.service';
import { voteSubmitSchema } from '@gala/shared/utils';
import { VOTE_CATEGORIES } from '@gala/shared/types';
import type { VoteCategory } from '@gala/shared/types';
import { requireAuth } from '../middleware/auth';
const router: IRouter = Router();
@@ -10,10 +11,9 @@ const router: IRouter = Router();
* POST /api/vote/submit
* Submit a vote (HTTP fallback for WebSocket)
*/
router.post('/submit', async (req, res, next) => {
router.post('/submit', requireAuth, async (req, res, next) => {
try {
// TODO: Get userId from auth middleware
const userId = req.headers['x-user-id'] as string;
const userId = (req as any).user?.userId as string;
if (!userId) {
return res.status(401).json({
success: false,
@@ -121,9 +121,9 @@ router.get('/results', async (_req, res, next) => {
* GET /api/vote/status
* Get user's vote status
*/
router.get('/status', async (req, res, next) => {
router.get('/status', requireAuth, async (req, res, next) => {
try {
const userId = req.headers['x-user-id'] as string;
const userId = (req as any).user?.userId as string;
if (!userId) {
return res.status(401).json({
success: false,

View File

@@ -0,0 +1,148 @@
import { Router, Request, Response } from 'express';
import { wechatMpService } from '../services/wechat-mp.service';
import { config } from '../config';
import { logger } from '../utils/logger';
const router = Router();
/**
* GET /api/mp/auth-url
* 获取公众号网页授权URL
* 前端在微信环境中调用此接口获取授权跳转URL
*/
router.get('/auth-url', async (req: Request, res: Response) => {
try {
if (!wechatMpService.isConfigured()) {
return res.json({
success: false,
error: 'WeChat MP not configured',
});
}
// 从query获取回调地址默认使用移动端URL
const redirectUri = (req.query.redirect_uri as string) || config.mobileClientUrl;
if (!isRedirectAllowed(redirectUri)) {
return res.status(400).json({
success: false,
error: 'Invalid redirect_uri',
});
}
// 生成随机state防止CSRF
const state = await wechatMpService.createState();
// 使用snsapi_base静默授权只获取openid
const scope = (req.query.scope as 'snsapi_base' | 'snsapi_userinfo') || 'snsapi_base';
const authUrl = wechatMpService.generateAuthUrl(redirectUri, state, scope);
logger.info({ redirectUri, scope }, 'Generated WeChat MP auth URL');
return res.json({
success: true,
data: {
authUrl,
state,
},
});
} catch (error) {
logger.error({ error }, 'Failed to generate auth URL');
return res.status(500).json({
success: false,
error: 'Internal server error',
});
}
});
/**
* POST /api/mp/login
* 用code完成登录
* 前端在授权回调后携带code调用此接口完成登录
*/
router.post('/login', async (req: Request, res: Response) => {
try {
const { code, state } = req.body;
if (!code) {
return res.status(400).json({
success: false,
error: 'Missing code parameter',
});
}
if (!state) {
return res.status(400).json({
success: false,
error: 'Missing state parameter',
});
}
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',
});
}
const result = await wechatMpService.login(code);
if (!result.success) {
return res.json({
success: false,
error: result.error,
});
}
return res.json({
success: true,
data: result.data,
});
} catch (error) {
logger.error({ error }, 'Failed to process MP login');
return res.status(500).json({
success: false,
error: 'Internal server error',
});
}
});
/**
* GET /api/mp/config
* 获取公众号配置状态(不返回敏感信息)
*/
router.get('/config', (_req: Request, res: Response) => {
return res.json({
success: true,
data: {
configured: wechatMpService.isConfigured(),
},
});
});
function isRedirectAllowed(redirectUri: string): boolean {
try {
const url = new URL(redirectUri);
if (!['http:', 'https:'].includes(url.protocol)) return false;
const allowlist = config.wechatMp.redirectAllowlist;
if (allowlist.length > 0) {
return allowlist.includes(url.host);
}
const fallbackHost = new URL(config.mobileClientUrl).host;
return url.host === fallbackHost;
} catch {
return false;
}
}
export default router;

View File

@@ -326,7 +326,6 @@ class AdminService extends EventEmitter {
async addVoteStamp(
programId: string,
userName: string,
department: string,
ticketType: string,
options?: { revokedProgramId?: string }
): Promise<{ success: boolean; stamp?: VoteStamp; programVotes?: number; totalVotes?: number; revokedProgramId?: string; revokedProgramVotes?: number }> {
@@ -353,7 +352,6 @@ class AdminService extends EventEmitter {
const stamp: VoteStamp = {
id: `stamp_${Date.now()}_${Math.random().toString(36).substr(2, 5)}`,
userName,
department,
ticketType,
x: 10 + Math.random() * 80, // Random X position (10-90%)
y: 10 + Math.random() * 80, // Random Y position (10-90%)

View File

@@ -1,26 +1,27 @@
/**
* Program Configuration Service
* Loads program config from JSON file and provides API for management
* Loads program config from database with JSON file fallback
*/
import * as fs from 'fs';
import * as path from 'path';
import { fileURLToPath } from 'url';
import { logger } from '../utils/logger';
import { prisma } from '../utils/prisma';
import type { VotingProgram } from '@gala/shared/types';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Default config path
// Default config path (fallback)
const CONFIG_PATH = path.join(__dirname, '../../config/programs.json');
export interface ProgramConfig {
id: string;
name: string;
teamName: string;
performer?: string; // 表演者
performer?: string;
order: number;
remark?: string; // 节目备注
remark?: string;
}
export interface AwardConfig {
@@ -28,6 +29,7 @@ export interface AwardConfig {
name: string;
icon: string;
order: number;
remark?: string;
}
export interface ProgramSettings {
@@ -44,15 +46,85 @@ export interface ProgramConfigFile {
class ProgramConfigService {
private config: ProgramConfigFile | null = null;
private configPath: string;
private useDatabase: boolean = true;
constructor() {
this.configPath = CONFIG_PATH;
}
/**
* Load config from file
* Load config from database or file
*/
async load(): Promise<void> {
try {
// Try loading from database first
const dbPrograms = await this.loadProgramsFromDb();
const dbAwards = await this.loadAwardsFromDb();
if (dbPrograms.length > 0 || dbAwards.length > 0) {
this.useDatabase = true;
this.config = {
programs: dbPrograms,
awards: dbAwards,
settings: this.getDefaultSettings(),
};
logger.info({
programCount: dbPrograms.length,
awardCount: dbAwards.length,
source: 'database'
}, 'Program config loaded from database');
return;
}
// Fallback to JSON file
this.useDatabase = false;
await this.loadFromFile();
} catch (error) {
logger.warn({ error }, 'Database not available, falling back to JSON file');
this.useDatabase = false;
await this.loadFromFile();
}
}
/**
* Load programs from database
*/
private async loadProgramsFromDb(): Promise<ProgramConfig[]> {
const programs = await prisma.program.findMany({
where: { isActive: true },
orderBy: { order: 'asc' },
});
return programs.map(p => ({
id: p.id,
name: p.name,
teamName: p.teamName,
performer: p.performer || undefined,
order: p.order,
remark: p.remark || undefined,
}));
}
/**
* Load awards from database
*/
private async loadAwardsFromDb(): Promise<AwardConfig[]> {
const awards = await prisma.award.findMany({
where: { isActive: true },
orderBy: { order: 'asc' },
});
return awards.map(a => ({
id: a.id,
name: a.name,
icon: a.icon,
order: a.order,
remark: a.remark || undefined,
}));
}
/**
* Load config from JSON file
*/
private async loadFromFile(): Promise<void> {
try {
if (fs.existsSync(this.configPath)) {
const content = fs.readFileSync(this.configPath, 'utf-8');
@@ -60,28 +132,28 @@ class ProgramConfigService {
logger.info({
programCount: this.config?.programs.length,
awardCount: this.config?.awards?.length || 0,
configPath: this.configPath
}, 'Program config loaded');
// Validate: programs.length === awards.length
if (this.config?.programs && this.config?.awards) {
if (this.config.programs.length !== this.config.awards.length) {
logger.warn({
programCount: this.config.programs.length,
awardCount: this.config.awards.length
}, 'Warning: program count does not match award count');
}
}
source: 'file'
}, 'Program config loaded from file');
} else {
logger.warn({ configPath: this.configPath }, 'Program config file not found, using defaults');
logger.warn({ configPath: this.configPath }, 'Config file not found, using defaults');
this.config = this.getDefaults();
}
} catch (error) {
logger.error({ error, configPath: this.configPath }, 'Failed to load program config');
logger.error({ error }, 'Failed to load config from file');
this.config = this.getDefaults();
}
}
/**
* Get default settings
*/
private getDefaultSettings(): ProgramSettings {
return {
allowLateCatch: true,
maxVotesPerUser: 7,
};
}
/**
* Get default configuration
*/
@@ -105,15 +177,12 @@ class ProgramConfigService {
{ id: 'craftsmanship', name: '匠心独韵奖', icon: '💎', order: 6 },
{ id: 'in_sync', name: '同频时代奖', icon: '📻', order: 7 },
],
settings: {
allowLateCatch: true,
maxVotesPerUser: 7,
},
settings: this.getDefaultSettings(),
};
}
/**
* Get all programs (for display/listing)
* Get all programs
*/
getPrograms(): ProgramConfig[] {
return this.config?.programs || this.getDefaults().programs;
@@ -134,7 +203,7 @@ class ProgramConfigService {
}
/**
* Convert config programs to VotingProgram format (with runtime fields)
* Convert config programs to VotingProgram format
*/
getVotingPrograms(): VotingProgram[] {
const programs = this.getPrograms();
@@ -157,21 +226,18 @@ class ProgramConfigService {
* Get settings
*/
getSettings(): ProgramSettings {
return this.config?.settings || this.getDefaults().settings;
return this.config?.settings || this.getDefaultSettings();
}
/**
* Update programs and save to file
* Update programs (database or file)
*/
async updatePrograms(programs: ProgramConfig[]): Promise<{ success: boolean; error?: string }> {
try {
if (!this.config) {
this.config = this.getDefaults();
if (this.useDatabase) {
return await this.updateProgramsInDb(programs);
}
this.config.programs = programs;
await this.saveToFile();
logger.info({ programCount: programs.length }, 'Programs updated');
return { success: true };
return await this.updateProgramsInFile(programs);
} catch (error) {
logger.error({ error }, 'Failed to update programs');
return { success: false, error: (error as Error).message };
@@ -179,17 +245,69 @@ class ProgramConfigService {
}
/**
* Update awards and save to file
* Update programs in database
*/
private async updateProgramsInDb(programs: ProgramConfig[]): Promise<{ success: boolean; error?: string }> {
// Use transaction to update all programs
await prisma.$transaction(async (tx) => {
// Deactivate all existing programs
await tx.program.updateMany({
data: { isActive: false }
});
// Upsert each program
for (const p of programs) {
await tx.program.upsert({
where: { id: p.id },
create: {
id: p.id,
name: p.name,
teamName: p.teamName,
performer: p.performer || '',
order: p.order,
remark: p.remark,
isActive: true,
},
update: {
name: p.name,
teamName: p.teamName,
performer: p.performer || '',
order: p.order,
remark: p.remark,
isActive: true,
},
});
}
});
// Reload config
await this.load();
logger.info({ programCount: programs.length }, 'Programs updated in database');
return { success: true };
}
/**
* Update programs in file
*/
private async updateProgramsInFile(programs: ProgramConfig[]): Promise<{ success: boolean; error?: string }> {
if (!this.config) {
this.config = this.getDefaults();
}
this.config.programs = programs;
await this.saveToFile();
logger.info({ programCount: programs.length }, 'Programs updated in file');
return { success: true };
}
/**
* Update awards (database or file)
*/
async updateAwards(awards: AwardConfig[]): Promise<{ success: boolean; error?: string }> {
try {
if (!this.config) {
this.config = this.getDefaults();
if (this.useDatabase) {
return await this.updateAwardsInDb(awards);
}
this.config.awards = awards;
await this.saveToFile();
logger.info({ awardCount: awards.length }, 'Awards updated');
return { success: true };
return await this.updateAwardsInFile(awards);
} catch (error) {
logger.error({ error }, 'Failed to update awards');
return { success: false, error: (error as Error).message };
@@ -197,7 +315,56 @@ class ProgramConfigService {
}
/**
* Update settings and save to file
* Update awards in database
*/
private async updateAwardsInDb(awards: AwardConfig[]): Promise<{ success: boolean; error?: string }> {
await prisma.$transaction(async (tx) => {
await tx.award.updateMany({
data: { isActive: false }
});
for (const a of awards) {
await tx.award.upsert({
where: { id: a.id },
create: {
id: a.id,
name: a.name,
icon: a.icon,
order: a.order,
remark: a.remark,
isActive: true,
},
update: {
name: a.name,
icon: a.icon,
order: a.order,
remark: a.remark,
isActive: true,
},
});
}
});
await this.load();
logger.info({ awardCount: awards.length }, 'Awards updated in database');
return { success: true };
}
/**
* Update awards in file
*/
private async updateAwardsInFile(awards: AwardConfig[]): Promise<{ success: boolean; error?: string }> {
if (!this.config) {
this.config = this.getDefaults();
}
this.config.awards = awards;
await this.saveToFile();
logger.info({ awardCount: awards.length }, 'Awards updated in file');
return { success: true };
}
/**
* Update settings
*/
async updateSettings(settings: Partial<ProgramSettings>): Promise<{ success: boolean; error?: string }> {
try {
@@ -205,7 +372,9 @@ class ProgramConfigService {
this.config = this.getDefaults();
}
this.config.settings = { ...this.config.settings, ...settings };
await this.saveToFile();
if (!this.useDatabase) {
await this.saveToFile();
}
logger.info({ settings: this.config.settings }, 'Program settings updated');
return { success: true };
} catch (error) {
@@ -223,11 +392,18 @@ class ProgramConfigService {
}
/**
* Get full config for API response
* Get full config
*/
getFullConfig(): ProgramConfigFile {
return this.config || this.getDefaults();
}
/**
* Check if using database
*/
isUsingDatabase(): boolean {
return this.useDatabase;
}
}
export const programConfigService = new ProgramConfigService();

View File

@@ -1,12 +1,11 @@
import { v4 as uuidv4 } from 'uuid';
import jwt from 'jsonwebtoken';
import { redis } from '../config/redis';
import { config } from '../config';
import { logger } from '../utils/logger';
import type { ScanTokenData, ScanLoginStatus } from '@gala/shared/types';
import { createSessionToken } from '../utils/auth';
const SCAN_TOKEN_PREFIX = 'scan:';
const SESSION_TOKEN_PREFIX = 'session:';
const SCAN_TOKEN_TTL = 5 * 60; // 5 minutes
const SESSION_TOKEN_TTL = 24 * 60 * 60; // 24 hours
@@ -145,8 +144,7 @@ class ScanLoginService {
*/
async confirmLogin(
scanToken: string,
userName: string,
department: string
userName: string
): Promise<{
success: boolean;
data?: {
@@ -176,22 +174,14 @@ class ScanLoginService {
// Generate user ID and session token
const userId = `user_${Date.now()}_${uuidv4().slice(0, 8)}`;
const sessionToken = jwt.sign(
{ userId, userName, department },
config.jwtSecret || 'gala-secret-key',
{ expiresIn: '24h' }
);
// Store session
await redis.setex(
`${SESSION_TOKEN_PREFIX}${sessionToken}`,
SESSION_TOKEN_TTL,
JSON.stringify({ userId, userName, department })
const sessionToken = await createSessionToken(
{ userId, userName, role: 'user' },
SESSION_TOKEN_TTL
);
// Update scan token status
tokenData.status = 'confirmed';
tokenData.userInfo = { userId, userName, department };
tokenData.userInfo = { userId, userName };
await redis.setex(
`${SCAN_TOKEN_PREFIX}${scanToken}`,

View File

@@ -0,0 +1,202 @@
import { randomUUID } from 'crypto';
import { config } from '../config';
import { logger } from '../utils/logger';
import { createSessionToken } from '../utils/auth';
import { redis } from '../config/redis';
import type { WechatAccessTokenResponse, WechatUserInfo } from '@gala/shared/types';
const SESSION_TOKEN_TTL = 24 * 60 * 60; // 24 hours
const STATE_TTL = 5 * 60; // 5 minutes
const STATE_PREFIX = 'wechat_mp:state:';
/**
* 微信公众号网页授权服务
* 用于在微信内H5页面进行OAuth授权登录
*/
class WechatMpService {
private readonly appId: string;
private readonly appSecret: string;
constructor() {
this.appId = config.wechatMp.appId;
this.appSecret = config.wechatMp.appSecret;
}
/**
* 检查公众号配置是否有效
*/
isConfigured(): boolean {
return !!(this.appId && this.appSecret);
}
/**
* 生成公众号网页授权URL
* @param redirectUri 授权后回调地址
* @param state 状态参数用于防止CSRF
* @param scope 授权作用域: snsapi_base(静默授权) 或 snsapi_userinfo(需用户确认)
*/
generateAuthUrl(
redirectUri: string,
state: string,
scope: 'snsapi_base' | 'snsapi_userinfo' = 'snsapi_base'
): string {
const baseUrl = 'https://open.weixin.qq.com/connect/oauth2/authorize';
const params = new URLSearchParams({
appid: this.appId,
redirect_uri: redirectUri,
response_type: 'code',
scope: scope,
state: state,
});
return `${baseUrl}?${params.toString()}#wechat_redirect`;
}
/**
* 创建并保存state防止CSRF
*/
async createState(): Promise<string> {
const state = randomUUID();
await redis.setex(`${STATE_PREFIX}${state}`, STATE_TTL, '1');
return state;
}
/**
* 校验并消费state
*/
async validateState(state: string): Promise<boolean> {
try {
const key = `${STATE_PREFIX}${state}`;
const exists = await redis.get(key);
if (!exists) return false;
await redis.del(key);
return true;
} catch (error) {
logger.error({ error }, 'Failed to validate WeChat MP state');
return false;
}
}
/**
* 用code换取access_token和openid
*/
async getAccessToken(code: string): Promise<{
success: boolean;
data?: { openid: string; accessToken: string };
error?: string;
}> {
try {
const url = 'https://api.weixin.qq.com/sns/oauth2/access_token';
const params = new URLSearchParams({
appid: this.appId,
secret: this.appSecret,
code: code,
grant_type: 'authorization_code',
});
const response = await fetch(`${url}?${params.toString()}`);
const data = (await response.json()) as WechatAccessTokenResponse;
if (data.errcode) {
logger.error({ errcode: data.errcode, errmsg: data.errmsg }, 'WeChat MP access_token error');
return { success: false, error: data.errmsg || 'Failed to get access token' };
}
if (!data.openid || !data.access_token) {
return { success: false, error: 'Invalid response from WeChat' };
}
logger.info({ openid: data.openid }, 'WeChat MP access_token obtained');
return {
success: true,
data: { openid: data.openid, accessToken: data.access_token },
};
} catch (error) {
logger.error({ error }, 'Failed to get WeChat MP access token');
return { success: false, error: 'Failed to get access token' };
}
}
/**
* 获取用户信息snsapi_userinfo时可用
*/
async getUserInfo(accessToken: string, openid: string): Promise<WechatUserInfo | null> {
try {
const url = 'https://api.weixin.qq.com/sns/userinfo';
const params = new URLSearchParams({
access_token: accessToken,
openid: openid,
lang: 'zh_CN',
});
const response = await fetch(`${url}?${params.toString()}`);
const data = (await response.json()) as { errcode?: number; errmsg?: string } & WechatUserInfo;
if (data.errcode) {
logger.error({ errcode: data.errcode, errmsg: data.errmsg }, 'WeChat MP userinfo error');
return null;
}
return data as WechatUserInfo;
} catch (error) {
logger.error({ error }, 'Failed to get WeChat MP user info');
return null;
}
}
/**
* 用code完成登录返回sessionToken
*/
async login(code: string): Promise<{
success: boolean;
data?: {
openid: string;
sessionToken: string;
userId: string;
userName: string;
userInfo?: WechatUserInfo;
};
error?: string;
}> {
try {
// 用code换取access_token
const tokenResult = await this.getAccessToken(code);
if (!tokenResult.success || !tokenResult.data) {
return { success: false, error: tokenResult.error };
}
const { openid, accessToken } = tokenResult.data;
// 尝试获取用户信息如果是snsapi_userinfo授权
const userInfo = await this.getUserInfo(accessToken, openid);
// 生成用户ID和名称
const userId = `wx_${openid.slice(0, 16)}`;
const userName = userInfo?.nickname || `微信用户_${openid.slice(-6)}`;
// 创建session token
const sessionToken = await createSessionToken(
{ userId, userName, openid, role: 'user' },
SESSION_TOKEN_TTL
);
logger.info({ openid, userId }, 'WeChat MP login successful');
return {
success: true,
data: {
openid,
sessionToken,
userId,
userName,
userInfo: userInfo || undefined,
},
};
} catch (error) {
logger.error({ error }, 'Failed to process WeChat MP login');
return { success: false, error: 'Failed to process login' };
}
}
}
export const wechatMpService = new WechatMpService();

View File

@@ -1,5 +1,4 @@
import { v4 as uuidv4 } from 'uuid';
import jwt from 'jsonwebtoken';
import { redis } from '../config/redis';
import { config } from '../config';
import { logger } from '../utils/logger';
@@ -8,9 +7,9 @@ import type {
WechatAccessTokenResponse,
WechatUserInfo,
} from '@gala/shared/types';
import { createSessionToken } from '../utils/auth';
const WECHAT_STATE_PREFIX = 'wechat:state:';
const SESSION_TOKEN_PREFIX = 'session:';
const STATE_TTL = 5 * 60; // 5 minutes
const SESSION_TOKEN_TTL = 24 * 60 * 60; // 24 hours
@@ -52,7 +51,7 @@ class WechatService {
*/
async createLoginState(pcSocketId: string): Promise<{
success: boolean;
data?: { authUrl: string; state: string; expiresAt: number };
data?: { appId: string; redirectUri: string; state: string; expiresAt: number };
error?: string;
}> {
if (!this.isConfigured()) {
@@ -77,13 +76,16 @@ class WechatService {
JSON.stringify(stateData)
);
const authUrl = this.generateAuthUrl(state);
logger.info({ state, pcSocketId }, 'WeChat login state created');
return {
success: true,
data: { authUrl, state, expiresAt },
data: {
appId: this.appId,
redirectUri: this.redirectUri,
state,
expiresAt,
},
};
} catch (error) {
logger.error({ error }, 'Failed to create WeChat login state');
@@ -204,17 +206,9 @@ class WechatService {
const userId = `wx_${openid.slice(0, 16)}`;
const userName = userInfo?.nickname || `微信用户_${openid.slice(-6)}`;
const sessionToken = jwt.sign(
{ userId, userName, openid },
config.jwtSecret || 'gala-secret-key',
{ expiresIn: '24h' }
);
// Store session
await redis.setex(
`${SESSION_TOKEN_PREFIX}${sessionToken}`,
SESSION_TOKEN_TTL,
JSON.stringify({ userId, userName, openid })
const sessionToken = await createSessionToken(
{ userId, userName, openid, role: 'user' },
SESSION_TOKEN_TTL
);
// Delete used state

View File

@@ -8,6 +8,7 @@ import { voteService } from '../services/vote.service';
import { votingEngine } from '../services/voting.engine';
import { adminService } from '../services/admin.service';
import { SOCKET_EVENTS, SOCKET_ROOMS, TICKET_TYPES, VOTE_KEYS, type TicketType } from '@gala/shared/constants';
import { extractBearerToken, verifySessionToken } from '../utils/auth';
import type {
ServerToClientEvents,
ClientToServerEvents,
@@ -32,6 +33,16 @@ export type GalaServer = Server<ClientToServerEvents, ServerToClientEvents, Inte
let io: GalaServer;
function getTokenFromSocket(socket: GalaSocket, payloadToken?: string): string | null {
if (payloadToken) return payloadToken;
const authToken = (socket.handshake.auth as any)?.token;
if (typeof authToken === 'string' && authToken.trim()) return authToken.trim();
const headerToken = socket.handshake.headers['x-session-token'];
if (typeof headerToken === 'string' && headerToken.trim()) return headerToken.trim();
const bearer = extractBearerToken(socket.handshake.headers.authorization);
return bearer || null;
}
/**
* Initialize Socket.io server
*/
@@ -191,32 +202,58 @@ async function handleJoin(
};
try {
const { userId, userName, role, department } = data;
const { userId, userName, role, sessionToken } = data;
const token = getTokenFromSocket(socket, sessionToken);
const authUser = token ? await verifySessionToken(token) : null;
if (token && !authUser) {
safeCallback({
success: false,
error: 'UNAUTHORIZED',
message: 'Invalid session token',
});
return;
}
if (role === 'admin' && (!authUser || authUser.role !== 'admin')) {
safeCallback({
success: false,
error: 'UNAUTHORIZED',
message: 'Admin access required',
});
return;
}
const effectiveRole = authUser?.role || role;
const effectiveUserId = authUser?.userId || userId;
const effectiveUserName = authUser?.userName || userName;
// Store user data in socket
socket.data.userId = userId;
socket.data.userName = userName;
socket.data.department = department || '未知部门';
socket.data.role = role;
socket.data.userId = effectiveUserId;
socket.data.userName = effectiveUserName;
socket.data.role = effectiveRole;
socket.data.connectedAt = new Date();
socket.data.sessionId = socket.id;
(socket.data as any).sessionToken = token || undefined;
(socket.data as any).authenticated = !!authUser;
// Join appropriate rooms
await socket.join(SOCKET_ROOMS.ALL);
if (role === 'user') {
if (effectiveRole === 'user') {
await socket.join(SOCKET_ROOMS.MOBILE_USERS);
} else if (role === 'screen') {
} else if (effectiveRole === 'screen') {
await socket.join(SOCKET_ROOMS.SCREEN_DISPLAY);
} else if (role === 'admin') {
} else if (effectiveRole === 'admin') {
await socket.join(SOCKET_ROOMS.ADMIN);
}
// Get user's voted categories and tickets
const votedCategories = await voteService.getUserVotedCategories(userId);
const userTickets = await redis.hgetall(VOTE_KEYS.userTickets(userId));
const votedCategories = await voteService.getUserVotedCategories(effectiveUserId);
const userTickets = await redis.hgetall(VOTE_KEYS.userTickets(effectiveUserId));
logger.info({ socketId: socket.id, userId, userName, role }, 'User joined');
logger.info({ socketId: socket.id, userId: effectiveUserId, userName: effectiveUserName, role: effectiveRole }, 'User joined');
// Broadcast user count update
const userCount = await getUserCount();
@@ -259,8 +296,9 @@ async function handleVoteSubmit(
};
const userId = socket.data.userId;
const authenticated = (socket.data as any).authenticated === true;
if (!userId) {
if (!userId || !authenticated) {
safeCallback({
success: false,
error: 'UNAUTHORIZED',
@@ -306,7 +344,6 @@ async function handleVoteSubmit(
const stampResult = await adminService.addVoteStamp(
data.candidateId,
socket.data.userName || '匿名用户',
socket.data.department || '未知部门',
category,
{ revokedProgramId: result.revoked_program }
);
@@ -359,7 +396,6 @@ async function handleVoteSubmit(
const stampResult = await adminService.addVoteStamp(
data.candidateId,
socket.data.userName || '匿名用户',
socket.data.department || '未知部门',
data.category as string
);

View File

@@ -0,0 +1,83 @@
import jwt from 'jsonwebtoken';
import { redis } from '../config/redis';
import { config } from '../config';
export type AuthRole = 'user' | 'admin' | 'screen';
export interface SessionData {
userId: string;
userName?: string;
role?: AuthRole;
openid?: string;
}
export interface AuthUser {
userId: string;
userName?: string;
role: AuthRole;
token: string;
openid?: string;
}
const SESSION_TOKEN_PREFIX = 'session:';
export function extractBearerToken(raw?: string | string[]): string | null {
if (!raw) return null;
const value = Array.isArray(raw) ? raw[0] : raw;
const parts = value.split(' ');
if (parts.length === 2 && /^Bearer$/i.test(parts[0])) {
return parts[1];
}
return null;
}
export async function createSessionToken(
data: SessionData,
ttlSeconds: number
): Promise<string> {
const payload = {
userId: data.userId,
userName: data.userName,
role: data.role || 'user',
openid: data.openid,
};
const token = jwt.sign(payload, config.jwtSecret, {
expiresIn: ttlSeconds,
});
await redis.setex(
`${SESSION_TOKEN_PREFIX}${token}`,
ttlSeconds,
JSON.stringify({
userId: data.userId,
userName: data.userName,
role: data.role || 'user',
openid: data.openid,
})
);
return token;
}
export async function verifySessionToken(token: string): Promise<AuthUser | null> {
try {
const payload = jwt.verify(token, config.jwtSecret) as SessionData & { role?: AuthRole };
const data = await redis.get(`${SESSION_TOKEN_PREFIX}${token}`);
if (!data) return null;
const session = JSON.parse(data) as SessionData;
const role = (payload.role || session.role || 'user') as AuthRole;
return {
userId: payload.userId || session.userId,
userName: payload.userName || session.userName,
role,
token,
openid: payload.openid || session.openid,
};
} catch {
return null;
}
}

View File

@@ -0,0 +1,3 @@
import { PrismaClient } from '@prisma/client';
export const prisma = new PrismaClient();

View File

@@ -69,7 +69,6 @@ export interface VotingProgram {
export interface VoteStamp {
id: string;
userName: string;
department: string;
ticketType: string;
x: number; // 随机 X 位置 (0-100%)
y: number; // 随机 Y 位置 (0-100%)
@@ -97,7 +96,7 @@ export interface LotteryWinner {
export interface MusicState {
isPlaying: boolean;
track: 'bgm' | 'lottery' | 'fanfare' | 'none';
track: 'bgm' | 'lottery' | 'fanfare' | 'award' | 'none';
volume: number;
}

View File

@@ -11,7 +11,6 @@ export interface ScanTokenData {
userInfo?: {
userId: string;
userName: string;
department: string;
};
}
@@ -38,7 +37,6 @@ export interface ValidateTokenResponse {
export interface ScanConfirmPayload {
scanToken: string;
userName: string;
department: string;
}
export interface ScanConfirmResponse {
@@ -56,7 +54,6 @@ export interface ScanStatusUpdatePayload {
userInfo?: {
userId: string;
userName: string;
department: string;
sessionToken: string;
};
}

View File

@@ -83,7 +83,6 @@ export interface DrawFilters {
export interface JoinPayload {
userId: string;
userName: string;
department?: string;
role: UserRole;
sessionToken?: string;
}
@@ -192,10 +191,11 @@ export interface InterServerEvents {
export interface SocketData {
userId: string;
userName: string;
department: string;
role: UserRole;
connectedAt: Date;
sessionId: string;
sessionToken?: string;
authenticated?: boolean;
}
// ============================================================================

View File

@@ -41,7 +41,7 @@
### 3.1 模块 A身份验证与连接
| ID | 测试场景 | 操作步骤 | 预期结果 | 状态 |
| :--- | :--- | :--- | :--- | :--- |
| A01 | 员工扫码登录 | 微信扫码访问首页 | 1. 显示“请输入姓名”<br>2. 提交后进入首页,显示已连接绿灯 | ☐ |
| A01 | 员工扫码登录 | 大屏显示入口二维码,用户微信扫码 | 1. 微信弹出授权确认页<br>2. 授权后自动跳转至投票页,显示"已连接"绿灯 | ☐ |
| A02 | 导播台登录 | 访问 `/admin/director-console` | 1. 拦截跳转至登录页<br>2. 输入 `20268888` 后进入控制台 | ☐ |
| A03 | 大屏连接 | 访问 `/screen/display-mode` | 1. 全屏显示“马到成功”主视觉<br>2. 左下角“在线人数”实时更新 | ☐ |