- Add scan login service with Redis-based token management - Add scan login API routes for token generation and validation - Add QRCodeLogin component for PC-side QR code display - Add EntryQRCode component for mass login scenarios - Add ScanLoginView for mobile-side login form - Add localStorage persistence for user identity - Add logout functionality to mobile client - Add auto-redirect for logged-in users - Add admin console control for QR code display on big screen - Add socket event forwarding from admin to screen display Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
220 lines
4.3 KiB
Vue
220 lines
4.3 KiB
Vue
<script setup lang="ts">
|
||
import { ref, onMounted, onUnmounted } from 'vue';
|
||
import QRCode from 'qrcode';
|
||
|
||
const props = defineProps<{
|
||
mobileUrl: string;
|
||
}>();
|
||
|
||
const emit = defineEmits<{
|
||
close: [];
|
||
}>();
|
||
|
||
const qrCodeDataUrl = ref<string>('');
|
||
|
||
// Generate QR code
|
||
async function generateQRCode() {
|
||
try {
|
||
qrCodeDataUrl.value = await QRCode.toDataURL(props.mobileUrl, {
|
||
width: 400,
|
||
margin: 2,
|
||
color: {
|
||
dark: '#1a1a2e',
|
||
light: '#ffffff',
|
||
},
|
||
});
|
||
} catch (err) {
|
||
console.error('Failed to generate QR code:', err);
|
||
}
|
||
}
|
||
|
||
// Handle ESC key to close
|
||
function handleKeydown(e: KeyboardEvent) {
|
||
if (e.key === 'Escape') {
|
||
emit('close');
|
||
}
|
||
}
|
||
|
||
onMounted(() => {
|
||
generateQRCode();
|
||
window.addEventListener('keydown', handleKeydown);
|
||
});
|
||
|
||
onUnmounted(() => {
|
||
window.removeEventListener('keydown', handleKeydown);
|
||
});
|
||
</script>
|
||
|
||
<template>
|
||
<div class="entry-qrcode-overlay" @click.self="emit('close')">
|
||
<div class="entry-qrcode-modal">
|
||
<!-- Close button -->
|
||
<button class="close-btn" @click="emit('close')">
|
||
<span>×</span>
|
||
</button>
|
||
|
||
<!-- Title -->
|
||
<h2 class="modal-title">扫码进入投票系统</h2>
|
||
<p class="modal-subtitle">请使用微信扫描下方二维码</p>
|
||
|
||
<!-- QR Code -->
|
||
<div class="qrcode-container">
|
||
<img v-if="qrCodeDataUrl" :src="qrCodeDataUrl" alt="入场二维码" class="qrcode-image" />
|
||
<div v-else class="qrcode-loading">生成中...</div>
|
||
</div>
|
||
|
||
<!-- URL hint -->
|
||
<p class="url-hint">{{ mobileUrl }}</p>
|
||
|
||
<!-- Instructions -->
|
||
<div class="instructions">
|
||
<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>
|
||
|
||
<!-- Footer hint -->
|
||
<p class="footer-hint">按 ESC 或点击空白处关闭</p>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<style lang="scss" scoped>
|
||
@use '../assets/styles/variables.scss' as *;
|
||
|
||
.entry-qrcode-overlay {
|
||
position: fixed;
|
||
inset: 0;
|
||
background: rgba(0, 0, 0, 0.85);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
z-index: 1000;
|
||
backdrop-filter: blur(10px);
|
||
}
|
||
|
||
.entry-qrcode-modal {
|
||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
||
border-radius: 24px;
|
||
padding: 48px 64px;
|
||
text-align: center;
|
||
position: relative;
|
||
border: 1px solid rgba($color-gold, 0.3);
|
||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
||
max-width: 90vw;
|
||
}
|
||
|
||
.close-btn {
|
||
position: absolute;
|
||
top: 16px;
|
||
right: 16px;
|
||
width: 40px;
|
||
height: 40px;
|
||
border-radius: 50%;
|
||
background: rgba(255, 255, 255, 0.1);
|
||
border: none;
|
||
color: $color-text-muted;
|
||
font-size: 24px;
|
||
cursor: pointer;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
transition: all 0.3s ease;
|
||
|
||
&:hover {
|
||
background: rgba(255, 255, 255, 0.2);
|
||
color: $color-text-light;
|
||
}
|
||
}
|
||
|
||
.modal-title {
|
||
font-size: 36px;
|
||
font-weight: bold;
|
||
color: $color-gold;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.modal-subtitle {
|
||
font-size: 18px;
|
||
color: $color-text-muted;
|
||
margin-bottom: 32px;
|
||
}
|
||
|
||
.qrcode-container {
|
||
background: white;
|
||
padding: 24px;
|
||
border-radius: 16px;
|
||
display: inline-block;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.qrcode-image {
|
||
width: 300px;
|
||
height: 300px;
|
||
display: block;
|
||
}
|
||
|
||
.qrcode-loading {
|
||
width: 300px;
|
||
height: 300px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: #666;
|
||
font-size: 18px;
|
||
}
|
||
|
||
.url-hint {
|
||
font-size: 14px;
|
||
color: $color-text-muted;
|
||
margin-bottom: 32px;
|
||
font-family: monospace;
|
||
}
|
||
|
||
.instructions {
|
||
display: flex;
|
||
justify-content: center;
|
||
gap: 48px;
|
||
margin-bottom: 32px;
|
||
}
|
||
|
||
.step {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
.step-num {
|
||
width: 32px;
|
||
height: 32px;
|
||
border-radius: 50%;
|
||
background: rgba($color-gold, 0.2);
|
||
color: $color-gold;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-weight: bold;
|
||
}
|
||
|
||
.step-text {
|
||
font-size: 14px;
|
||
color: $color-text-light;
|
||
}
|
||
|
||
.footer-hint {
|
||
font-size: 12px;
|
||
color: $color-text-muted;
|
||
opacity: 0.6;
|
||
}
|
||
</style>
|