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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
// 可以在这里处理登录成功后的逻辑
|
||||
|
||||
@@ -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() });
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user