Compare commits
30 Commits
feat/add-w
...
d22952a423
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d22952a423 | ||
|
|
c2731ce1dc | ||
|
|
99fe68e851 | ||
|
|
83bc4da3cc | ||
|
|
bd62dff06a | ||
|
|
baacee50e6 | ||
|
|
69c789ba9d | ||
|
|
0f484f63d4 | ||
|
|
307811886b | ||
|
|
92d560445e | ||
|
|
7fea6b8578 | ||
|
|
83bf1d3a43 | ||
|
|
39caecdd95 | ||
|
|
19b7823d42 | ||
|
|
a27134b82e | ||
|
|
7e15fcb377 | ||
|
|
655f77ec3e | ||
|
|
65b153e5df | ||
|
|
eee34916b0 | ||
|
|
6ccc571be2 | ||
|
|
8ab575b14a | ||
|
|
8f21ff6fd9 | ||
|
|
406a5afa33 | ||
|
|
b5fa4c7086 | ||
|
|
9f94f362d4 | ||
|
|
7a3b9a3694 | ||
|
|
9b11f99fed | ||
|
|
b53e732ffa | ||
|
|
2cb9032187 | ||
| a40c8b6045 |
@@ -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
2
.gitignore
vendored
@@ -41,3 +41,5 @@ packages/server/prisma/*.db-journal
|
||||
# Data files
|
||||
参与抽奖人员名单.xlsx
|
||||
测试清单.md
|
||||
|
||||
.codeartsdoer
|
||||
@@ -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
99
CLAUDE.md
Normal 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` 说明。
|
||||
35
DEPLOY.md
35
DEPLOY.md
@@ -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
|
||||
|
||||
1
MP_verify_ewdeSP35l53E3UEJ.txt
Normal file
1
MP_verify_ewdeSP35l53E3UEJ.txt
Normal file
@@ -0,0 +1 @@
|
||||
ewdeSP35l53E3UEJ
|
||||
18
README.md
18
README.md
@@ -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)。
|
||||
|
||||
## 测试
|
||||
|
||||
1
packages/client-mobile/src/components.d.ts
vendored
1
packages/client-mobile/src/components.d.ts
vendored
@@ -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']
|
||||
|
||||
419
packages/client-mobile/src/components/OnboardingTour.vue
Normal file
419
packages/client-mobile/src/components/OnboardingTour.vue
Normal 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>
|
||||
@@ -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 '表演者';
|
||||
});
|
||||
|
||||
// 当前选中奖项的备注(用于移动端引导)
|
||||
|
||||
@@ -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>
|
||||
|
||||
64
packages/client-mobile/src/composables/useOnboarding.ts
Normal file
64
packages/client-mobile/src/composables/useOnboarding.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 {
|
||||
|
||||
BIN
packages/client-screen/public/audio/award.mp3
Normal file
BIN
packages/client-screen/public/audio/award.mp3
Normal file
Binary file not shown.
@@ -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生成二维码
|
||||
// 用户扫码后在微信内打开H5,H5会自动检测微信环境并跳转授权
|
||||
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>
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
// 可以在这里处理登录成功后的逻辑
|
||||
|
||||
@@ -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 ? '已连接' : '连接中...' }}
|
||||
|
||||
@@ -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 # 长时间停留
|
||||
|
||||
@@ -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 }}"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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())
|
||||
|
||||
110
packages/server/prisma/seed.ts
Normal file
110
packages/server/prisma/seed.ts
Normal 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();
|
||||
});
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
64
packages/server/src/middleware/auth.ts
Normal file
64
packages/server/src/middleware/auth.ts
Normal 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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
45
packages/server/src/routes/public.routes.ts
Normal file
45
packages/server/src/routes/public.routes.ts
Normal 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;
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
148
packages/server/src/routes/wechat-mp.routes.ts
Normal file
148
packages/server/src/routes/wechat-mp.routes.ts
Normal 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;
|
||||
@@ -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%)
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
202
packages/server/src/services/wechat-mp.service.ts
Normal file
202
packages/server/src/services/wechat-mp.service.ts
Normal 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();
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
|
||||
83
packages/server/src/utils/auth.ts
Normal file
83
packages/server/src/utils/auth.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
3
packages/server/src/utils/prisma.ts
Normal file
3
packages/server/src/utils/prisma.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
export const prisma = new PrismaClient();
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -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. 左下角“在线人数”实时更新 | ☐ |
|
||||
|
||||
|
||||
Reference in New Issue
Block a user