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">
import { ref, onMounted } from 'vue';
import { onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { useConnectionStore } from '../stores/connection';
import { showToast } from 'vant';
const router = useRouter();
const connectionStore = useConnectionStore();
const userName = ref('');
const userDept = ref('技术部');
const isLoading = ref(false);
// Check if already logged in
onMounted(() => {
if (connectionStore.userId && connectionStore.userName && connectionStore.userName !== '访客') {
@@ -18,26 +13,6 @@ onMounted(() => {
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>
<template>
@@ -59,40 +34,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 +202,58 @@ async function handleEnter() {
}
}
.form-section {
.scan-section {
margin-bottom: $spacing-xl;
text-align: center;
.input-wrapper {
background: $color-bg-card;
margin-bottom: $spacing-md;
padding: $spacing-xs;
.scan-hint {
margin-bottom: $spacing-xl;
:deep(.van-field) {
background: transparent;
.hint-title {
font-size: $font-size-xl;
font-weight: bold;
color: $color-text-primary;
margin: $spacing-md 0 $spacing-xs;
}
.van-field__control {
text-align: center;
font-size: $font-size-lg;
}
.hint-desc {
font-size: $font-size-md;
color: $color-text-secondary;
}
}
.enter-btn {
background: linear-gradient(135deg, $color-primary-light 0%, $color-primary 100%);
border: none;
height: 48px;
font-size: $font-size-lg;
font-weight: 500;
box-shadow: $shadow-md;
.steps {
display: flex;
justify-content: center;
gap: $spacing-lg;
padding: $spacing-md;
background: rgba($color-primary, 0.05);
border-radius: $radius-lg;
}
.step {
display: flex;
flex-direction: column;
align-items: center;
gap: $spacing-xs;
}
.step-num {
width: 28px;
height: 28px;
border-radius: 50%;
background: $color-primary;
color: white;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
font-size: $font-size-sm;
}
.step-text {
font-size: $font-size-xs;
color: $color-text-secondary;
}
}

View File

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

View File

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

View File

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

View File

@@ -2,6 +2,7 @@ import express, { Application } from 'express';
import cors from 'cors';
import helmet from 'helmet';
import compression from 'compression';
import path from 'path';
import { config } from './config';
import { logger } from './utils/logger';
import { errorHandler } from './middleware/errorHandler';
@@ -51,6 +52,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() });

View File

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