feat: update login flow and add firework effects

- Update mobile HomeView to show WeChat scan login instructions
- Remove manual name/department input form from mobile client
- Add firework particle effects to big screen background
- Remove department field from login flow types

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
empty
2026-02-03 17:18:03 +08:00
parent a40c8b6045
commit 2cb9032187
6 changed files with 219 additions and 84 deletions

View File

@@ -1,16 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue'; import { onMounted } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { useConnectionStore } from '../stores/connection'; import { useConnectionStore } from '../stores/connection';
import { showToast } from 'vant';
const router = useRouter(); const router = useRouter();
const connectionStore = useConnectionStore(); const connectionStore = useConnectionStore();
const userName = ref('');
const userDept = ref('技术部');
const isLoading = ref(false);
// Check if already logged in // Check if already logged in
onMounted(() => { onMounted(() => {
if (connectionStore.userId && connectionStore.userName && connectionStore.userName !== '访客') { if (connectionStore.userId && connectionStore.userName && connectionStore.userName !== '访客') {
@@ -18,26 +13,6 @@ onMounted(() => {
router.replace('/vote'); router.replace('/vote');
} }
}); });
async function handleEnter() {
if (!userName.value.trim()) {
showToast('请输入您的姓名');
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');
}
</script> </script>
<template> <template>
@@ -59,40 +34,27 @@ async function handleEnter() {
<p class="subtitle">投票 · 抽奖 · 互动</p> <p class="subtitle">投票 · 抽奖 · 互动</p>
</div> </div>
<div class="form-section"> <div class="scan-section">
<div class="input-wrapper guochao-border"> <div class="scan-hint">
<van-field <van-icon name="scan" size="64" color="#c41230" />
v-model="userName" <p class="hint-title">请使用微信扫码登录</p>
placeholder="请输入您的姓名" <p class="hint-desc">请前往大屏幕扫描二维码进入年会</p>
: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> </div>
<van-button <div class="steps">
class="enter-btn" <div class="step">
type="primary" <span class="step-num">1</span>
block <span class="step-text">前往大屏幕</span>
round </div>
:loading="isLoading" <div class="step">
loading-text="进入中..." <span class="step-num">2</span>
@click="handleEnter" <span class="step-text">微信扫描二维码</span>
> </div>
进入年会 <div class="step">
</van-button> <span class="step-num">3</span>
<span class="step-text">授权后自动登录</span>
</div>
</div>
</div> </div>
<!-- Features --> <!-- Features -->
@@ -240,31 +202,58 @@ async function handleEnter() {
} }
} }
.form-section { .scan-section {
margin-bottom: $spacing-xl; margin-bottom: $spacing-xl;
text-align: center;
.input-wrapper { .scan-hint {
background: $color-bg-card; margin-bottom: $spacing-xl;
margin-bottom: $spacing-md;
padding: $spacing-xs;
:deep(.van-field) { .hint-title {
background: transparent; font-size: $font-size-xl;
font-weight: bold;
color: $color-text-primary;
margin: $spacing-md 0 $spacing-xs;
}
.van-field__control { .hint-desc {
text-align: center; font-size: $font-size-md;
font-size: $font-size-lg; color: $color-text-secondary;
}
} }
} }
.enter-btn { .steps {
background: linear-gradient(135deg, $color-primary-light 0%, $color-primary 100%); display: flex;
border: none; justify-content: center;
height: 48px; gap: $spacing-lg;
font-size: $font-size-lg; padding: $spacing-md;
font-weight: 500; background: rgba($color-primary, 0.05);
box-shadow: $shadow-md; border-radius: $radius-lg;
}
.step {
display: flex;
flex-direction: column;
align-items: center;
gap: $spacing-xs;
}
.step-num {
width: 28px;
height: 28px;
border-radius: 50%;
background: $color-primary;
color: white;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
font-size: $font-size-sm;
}
.step-text {
font-size: $font-size-xs;
color: $color-text-secondary;
} }
} }

View File

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

View File

@@ -6,8 +6,22 @@ const COLORS = {
gold: 0xf0c239, gold: 0xf0c239,
goldDark: 0xd4a84b, goldDark: 0xd4a84b,
goldLight: 0xffd700, 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 { interface Particle {
x: number; x: number;
y: number; y: number;
@@ -17,9 +31,11 @@ interface Particle {
alpha: number; alpha: number;
rotation: number; rotation: number;
rotationSpeed: number; rotationSpeed: number;
type: 'dust' | 'symbol' | 'streak'; type: 'dust' | 'symbol' | 'streak' | 'firework-trail' | 'firework-spark';
life: number; life: number;
maxLife: number; maxLife: number;
color?: number;
gravity?: number;
} }
export class BackgroundEffect { export class BackgroundEffect {
@@ -29,6 +45,7 @@ export class BackgroundEffect {
private dustLayer: Graphics; private dustLayer: Graphics;
private symbolLayer: Graphics; private symbolLayer: Graphics;
private streakLayer: Graphics; private streakLayer: Graphics;
private fireworkLayer: Graphics;
private time = 0; private time = 0;
private windOffset = 0; private windOffset = 0;
private isDestroyed = false; private isDestroyed = false;
@@ -37,6 +54,7 @@ export class BackgroundEffect {
private readonly DUST_COUNT = 120; private readonly DUST_COUNT = 120;
private readonly SYMBOL_COUNT = 25; private readonly SYMBOL_COUNT = 25;
private readonly STREAK_INTERVAL = 3000; // ms between streaks private readonly STREAK_INTERVAL = 3000; // ms between streaks
private readonly FIREWORK_INTERVAL = 4000; // ms between fireworks
constructor() { constructor() {
this.app = new Application(); this.app = new Application();
@@ -44,6 +62,7 @@ export class BackgroundEffect {
this.dustLayer = new Graphics(); this.dustLayer = new Graphics();
this.symbolLayer = new Graphics(); this.symbolLayer = new Graphics();
this.streakLayer = new Graphics(); this.streakLayer = new Graphics();
this.fireworkLayer = new Graphics();
} }
async init(canvas: HTMLCanvasElement): Promise<void> { async init(canvas: HTMLCanvasElement): Promise<void> {
@@ -61,6 +80,7 @@ export class BackgroundEffect {
this.container.addChild(this.dustLayer); this.container.addChild(this.dustLayer);
this.container.addChild(this.symbolLayer); this.container.addChild(this.symbolLayer);
this.container.addChild(this.streakLayer); this.container.addChild(this.streakLayer);
this.container.addChild(this.fireworkLayer);
this.app.stage.addChild(this.container); this.app.stage.addChild(this.container);
// Draw vignette overlay // Draw vignette overlay
@@ -76,6 +96,9 @@ export class BackgroundEffect {
// Spawn streaks periodically // Spawn streaks periodically
this.spawnStreakLoop(); this.spawnStreakLoop();
// Spawn fireworks periodically
this.spawnFireworkLoop();
// Handle resize // Handle resize
window.addEventListener('resize', this.handleResize.bind(this)); window.addEventListener('resize', this.handleResize.bind(this));
} }
@@ -157,6 +180,78 @@ export class BackgroundEffect {
setTimeout(() => this.spawnStreakLoop(), this.STREAK_INTERVAL + Math.random() * 2000); 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 { private update(ticker: Ticker): void {
if (this.isDestroyed) return; if (this.isDestroyed) return;
@@ -167,6 +262,7 @@ export class BackgroundEffect {
this.dustLayer.clear(); this.dustLayer.clear();
this.symbolLayer.clear(); this.symbolLayer.clear();
this.streakLayer.clear(); this.streakLayer.clear();
this.fireworkLayer.clear();
const w = this.app.screen.width; const w = this.app.screen.width;
const h = this.app.screen.height; const h = this.app.screen.height;
@@ -185,6 +281,25 @@ export class BackgroundEffect {
return false; return false;
} }
p.alpha = 0.9 * (1 - p.life / p.maxLife); 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') { } else if (p.type === 'symbol') {
// Reset symbols that go off top // Reset symbols that go off top
if (p.y < -20) { if (p.y < -20) {
@@ -210,7 +325,9 @@ export class BackgroundEffect {
? this.dustLayer ? this.dustLayer
: p.type === 'symbol' : p.type === 'symbol'
? this.symbolLayer ? this.symbolLayer
: this.streakLayer; : (p.type === 'firework-trail' || p.type === 'firework-spark')
? this.fireworkLayer
: this.streakLayer;
if (p.type === 'dust') { if (p.type === 'dust') {
// Simple gold circle // Simple gold circle
@@ -250,6 +367,35 @@ export class BackgroundEffect {
// Draw head // Draw head
layer.circle(p.x, p.y, p.size); layer.circle(p.x, p.y, p.size);
layer.fill({ color: COLORS.goldLight, alpha: p.alpha }); layer.fill({ color: COLORS.goldLight, alpha: p.alpha });
} else if (p.type === 'firework-trail') {
// Rising firework with sparkle trail
const trailLength = 30;
for (let i = 0; i < 8; i++) {
const ratio = i / 8;
const tx = p.x + (Math.random() - 0.5) * 3;
const ty = p.y + ratio * trailLength;
const ta = p.alpha * (1 - ratio) * 0.6;
const ts = p.size * (1 - ratio * 0.5);
layer.circle(tx, ty, ts);
layer.fill({ color: p.color || COLORS.gold, alpha: ta });
}
// Bright head
layer.circle(p.x, p.y, p.size + 1);
layer.fill({ color: COLORS.fireworkWhite, alpha: p.alpha });
} else if (p.type === 'firework-spark') {
// Exploding spark with small trail
const trailLen = 5;
for (let i = 0; i < 3; i++) {
const ratio = i / 3;
const tx = p.x - p.vx * ratio * trailLen;
const ty = p.y - p.vy * ratio * trailLen;
const ta = p.alpha * (1 - ratio) * 0.5;
layer.circle(tx, ty, p.size * (1 - ratio * 0.3));
layer.fill({ color: p.color || COLORS.gold, alpha: ta });
}
// Spark head
layer.circle(p.x, p.y, p.size);
layer.fill({ color: p.color || COLORS.gold, alpha: p.alpha });
} }
} }

View File

@@ -13,7 +13,7 @@ const showQRLogin = ref(false);
// Mobile URL for entry QR code - use environment variable or fallback to current origin // 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; 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); console.log('Login success:', data);
showQRLogin.value = false; showQRLogin.value = false;
// 可以在这里处理登录成功后的逻辑 // 可以在这里处理登录成功后的逻辑

View File

@@ -2,6 +2,7 @@ import express, { Application } from 'express';
import cors from 'cors'; import cors from 'cors';
import helmet from 'helmet'; import helmet from 'helmet';
import compression from 'compression'; import compression from 'compression';
import path from 'path';
import { config } from './config'; import { config } from './config';
import { logger } from './utils/logger'; import { logger } from './utils/logger';
import { errorHandler } from './middleware/errorHandler'; import { errorHandler } from './middleware/errorHandler';
@@ -51,6 +52,9 @@ app.use(express.urlencoded({ extended: true }));
// Request logging // Request logging
app.use(requestLogger); app.use(requestLogger);
// Static files (for WeChat domain verification, etc.)
app.use(express.static(path.join(__dirname, '../public')));
// Health check // Health check
app.get('/health', (_req, res) => { app.get('/health', (_req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() }); res.json({ status: 'ok', timestamp: new Date().toISOString() });

View File

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