Files
check_in/templates/wall.html
2026-01-21 21:43:56 +08:00

721 lines
25 KiB
HTML

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ config.event_title }} - 签到大屏</title>
<style>
:root {
--primary-color: {{ config.primary_color }};
--secondary-color: {{ config.secondary_color }};
--bg-color: {{ config.bg_color }};
--card-bg: rgba(12, 24, 50, 0.6);
--text-color: #ffffff;
--header-img: url('{{ config.header_image }}');
--bg-opacity: {{ config.wall_config.bg_opacity if config.wall_config and config.wall_config.bg_opacity is not none else 0.3 }};
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'PingFang SC', 'Microsoft YaHei', sans-serif;
background-color: var(--bg-color);
color: var(--text-color);
overflow: hidden;
height: 100vh;
width: 100vw;
position: relative;
}
/* Simplified Background */
.bg-layer {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-image: var(--header-img);
background-size: cover;
background-position: center;
opacity: var(--bg-opacity);
z-index: -3;
/* Dynamic blur: no blur if opacity is 1 */
filter: {{ 'none' if config.wall_config and config.wall_config.bg_opacity == 1 else 'blur(10px)' }};
}
/* Subtle overlay to ensure text contrast */
.clean-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.2);
z-index: -2;
}
/* Header */
header {
position: absolute;
top: 30px;
left: 0;
width: 100%;
text-align: center;
z-index: 20; /* Above bubbles */
pointer-events: none; /* Let clicks pass through */
}
h1 {
font-size: 3rem;
color: #ffffff;
font-weight: 900;
text-shadow: 0 0 20px rgba(0, 0, 0, 0.5);
margin: 0;
letter-spacing: 2px;
}
.subtitle {
font-size: 1.2rem;
color: var(--primary-color);
letter-spacing: 4px;
text-transform: uppercase;
margin-bottom: 10px;
opacity: 0.9;
text-shadow: 0 0 5px rgba(0, 0, 0, 0.5);
}
/* Container for User Bubbles */
#bubble-container {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1;
overflow: hidden;
}
.user-bubble {
position: absolute;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 60px;
height: 60px; /* Reduced from 80px */
transition: transform 0.8s cubic-bezier(0.2, 0.8, 0.2, 1), opacity 0.5s ease;
will-change: transform, left, top;
}
.bubble-avatar {
width: 100%; /* Fill container */
height: 100%; /* Fill container */
border-radius: 50%;
background: linear-gradient(135deg, var(--secondary-color), var(--primary-color));
border: 2px solid rgba(255, 255, 255, 0.8);
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
font-size: 0.9rem; /* Reduced font size */
color: #fff;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3);
margin-bottom: 0; /* Removed margin */
overflow: hidden;
text-align: center;
padding: 2px;
line-height: 1.1;
word-break: break-all;
}
.bubble-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.bubble-name {
font-size: 0.9rem;
color: #fff;
text-shadow: 0 2px 4px rgba(0,0,0,0.8);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100px;
text-align: center;
background: rgba(0,0,0,0.3);
padding: 2px 6px;
border-radius: 10px;
}
/* Central Spotlight Card */
.spotlight-container {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -45%);
width: 80%;
max-width: 900px;
z-index: 10;
pointer-events: none;
}
.spotlight-card {
background: rgba(10, 20, 40, 0.85);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 20px;
padding: 50px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
text-align: center;
opacity: 0;
transform: scale(0.9) translateY(20px);
transition: all 0.8s cubic-bezier(0.2, 0.8, 0.2, 1);
}
.spotlight-card.active {
opacity: 1;
transform: scale(1) translateY(0);
}
.user-info {
font-size: 1.8rem;
color: #b0c4de;
margin-bottom: 30px;
display: flex;
align-items: center;
justify-content: center;
gap: 15px;
}
.user-avatar-large {
width: 80px;
height: 80px;
border-radius: 50%;
background: linear-gradient(135deg, var(--secondary-color), var(--primary-color));
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
font-size: 2rem;
color: #fff;
border: 3px solid rgba(255,255,255,0.5);
}
.company-name {
font-size: 3rem;
font-weight: 800;
color: #fff;
margin-bottom: 20px;
background: linear-gradient(90deg, #fff, #b3d9ff);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
/* Stats Counter */
.stats-counter {
position: absolute;
bottom: 30px;
right: 40px;
text-align: right;
z-index: 20;
background: rgba(0,0,0,0.3);
padding: 10px 20px;
border-radius: 10px;
border: 1px solid rgba(255,255,255,0.1);
}
.stats-number {
font-size: 2rem;
font-weight: bold;
color: var(--primary-color);
}
.stats-label {
font-size: 0.8rem;
color: #aaa;
}
/* Mobile Learn More Button */
.mobile-btn-container {
display: none;
position: absolute;
bottom: 80px;
left: 50%;
transform: translateX(-50%);
z-index: 30;
width: 80%;
text-align: center;
}
.learn-more-btn {
background: linear-gradient(90deg, var(--secondary-color), var(--primary-color));
color: white;
padding: 12px 30px;
border-radius: 50px;
text-decoration: none;
font-weight: bold;
box-shadow: 0 5px 20px rgba(0, 242, 255, 0.4);
display: inline-block;
transition: transform 0.3s;
}
.learn-more-btn:active {
transform: scale(0.95);
}
/* Mobile Optimization */
@media (max-width: 768px) {
.mobile-btn-container {
display: block;
}
h1 {
font-size: 1.8rem;
}
.subtitle {
font-size: 0.8rem;
letter-spacing: 2px;
}
.spotlight-card {
padding: 20px;
width: 90%;
}
.company-name {
font-size: 1.5rem;
}
.user-info {
font-size: 1.2rem;
flex-direction: column;
gap: 5px;
}
.user-avatar-large {
width: 60px;
height: 60px;
font-size: 1.5rem;
}
#card-extra {
font-size: 1rem !important;
}
.stats-counter {
bottom: 10px;
right: 10px;
padding: 5px 10px;
}
.stats-number {
font-size: 1.5rem;
}
}
</style>
</head>
<body>
{% set wc = config.wall_config if config.wall_config else {} %}
{% if wc.show_title != false %}
<header>
<div class="subtitle">{{ config.event_sub_title }}</div>
<h1>{{ config.event_title }}</h1>
</header>
{% endif %}
<div class="bg-layer"></div>
<div class="clean-overlay"></div>
<div id="bubble-container"></div>
{% set wc = config.wall_config if config.wall_config else {} %}
{% set show = wc.show_fields if wc.show_fields else {} %}
<div class="spotlight-container">
<div class="spotlight-card" id="card">
<div class="user-info">
{% if show.name != false %}
<div class="user-avatar-large" id="card-avatar">?</div>
<div>
<span id="card-name" style="font-weight: bold; color: white;"></span>
{% if show.position != false %}
<span id="card-position" style="font-size: 0.8em; margin-left: 10px; color: var(--primary-color);"></span>
{% endif %}
</div>
{% endif %}
</div>
{% if show.company_name != false %}
<div class="company-name" id="card-company"></div>
{% endif %}
{% if show.vision_2026 != false or show.business_scope != false %}
<div id="card-extra" style="font-size: 1.4rem; color: #dbe4ff; font-style: italic; margin-top: 20px;"></div>
{% endif %}
</div>
</div>
{% if wc.learn_more_url %}
<div class="mobile-btn-container">
<a href="{{ wc.learn_more_url }}" class="learn-more-btn" target="_blank">了解我们</a>
</div>
{% endif %}
<div class="stats-counter">
<div class="stats-number" id="checkin-count">0</div>
<div class="stats-label">已签到嘉宾</div>
</div>
<script>
// --- Configuration ---
const CONFIG = {
gridThreshold: 20, // Switch to grid when users > 20
maxDisplay: 30, // Maximum bubbles to display at once
rotationInterval: 10000, // Rotation interval in ms (10s)
bubbleSize: 60, // Width of bubble
bubbleHeight: 60, // Height of bubble
speed: 1.5, // Floating speed
};
const SHOW_VISION = {{ 'true' if show.vision_2026 != false else 'false' }};
const SHOW_SCOPE = {{ 'true' if show.business_scope != false else 'false' }};
// --- State ---
let allUsers = []; // All fetched users
let displayedUsers = []; // Currently displayed users
let bubbles = []; // Array of UserBubble instances
let currentIndex = 0; // For spotlight
let rotationIndex = 0; // For batch rotation
let rotationTimer = null;
let isGridMode = false;
const container = document.getElementById('bubble-container');
let width = window.innerWidth;
let height = window.innerHeight;
window.addEventListener('resize', () => {
width = window.innerWidth;
height = window.innerHeight;
if (isGridMode) arrangeGrid();
});
// --- Class: UserBubble ---
class UserBubble {
constructor(user) {
this.user = user;
this.id = user.gsdh_id || user.name + Math.random(); // Fallback ID
this.element = document.createElement('div');
this.element.className = 'user-bubble';
// Content
const name = user.name || '嘉宾';
// Dynamic font size based on name length
let fontSize = '0.9rem';
if (name.length > 3) fontSize = '0.7rem';
if (name.length > 4) fontSize = '0.6rem';
this.element.innerHTML = `
<div class="bubble-avatar" style="font-size: ${fontSize}">${name}</div>
`;
// Interaction: Click to show in spotlight
this.element.style.cursor = 'pointer';
this.element.addEventListener('click', (e) => {
e.stopPropagation();
showUserManual(this.user);
});
// Initial Fade In
this.element.style.opacity = '0';
container.appendChild(this.element);
// Trigger reflow for transition
requestAnimationFrame(() => {
this.element.style.opacity = '1';
});
// Initial Position (Random)
this.x = Math.random() * (width - CONFIG.bubbleSize);
this.y = Math.random() * (height - CONFIG.bubbleHeight);
// Velocity for floating
this.vx = (Math.random() - 0.5) * CONFIG.speed;
this.vy = (Math.random() - 0.5) * CONFIG.speed;
// Set initial DOM position
this.updatePosition();
}
updatePosition() {
this.element.style.transform = `translate(${this.x}px, ${this.y}px)`;
}
float() {
if (isGridMode) return; // Do not float in grid mode
this.x += this.vx;
this.y += this.vy;
// Bounce off edges
if (this.x <= 0 || this.x >= width - CONFIG.bubbleSize) this.vx *= -1;
if (this.y <= 0 || this.y >= height - CONFIG.bubbleHeight) this.vy *= -1;
this.updatePosition();
}
moveTo(targetX, targetY) {
// Use CSS transition for smooth movement to grid
this.x = targetX;
this.y = targetY;
this.updatePosition();
}
remove() {
this.element.style.opacity = '0';
this.element.style.transform = `translate(${this.x}px, ${this.y}px) scale(0.5)`;
setTimeout(() => {
if (this.element.parentNode) {
this.element.parentNode.removeChild(this.element);
}
}, 500); // Wait for transition
}
}
// --- Logic: Layout & Display Manager ---
function updateDisplay() {
// Determine which users to show
let targetUsers = [];
if (allUsers.length <= CONFIG.maxDisplay) {
targetUsers = [...allUsers];
// Reset rotation index if we fit in one page
rotationIndex = 0;
} else {
// Rotation Logic
// If rotationIndex is out of bounds, reset
if (rotationIndex >= allUsers.length) rotationIndex = 0;
// Get slice
targetUsers = allUsers.slice(rotationIndex, rotationIndex + CONFIG.maxDisplay);
// Wrap around if needed (though user asked for "batch", maybe wrapping to fill page is better?)
// If we want to maintain constant count:
if (targetUsers.length < CONFIG.maxDisplay) {
const remaining = CONFIG.maxDisplay - targetUsers.length;
targetUsers = targetUsers.concat(allUsers.slice(0, remaining));
}
}
displayedUsers = targetUsers;
syncBubbles(targetUsers);
// Update Grid/Float mode based on DISPLAYED count (usually maxDisplay if full)
// But config says switch to grid when users > 20.
// If maxDisplay is 30, then 20-30 will be grid.
const count = displayedUsers.length;
const wasGrid = isGridMode;
isGridMode = count > CONFIG.gridThreshold;
if (isGridMode) {
arrangeGrid();
} else if (wasGrid && !isGridMode) {
// Switching back to float, ensure they have random velocities if needed
// But typically we don't go back from grid to float unless people leave.
}
}
function syncBubbles(targetUsers) {
const targetIds = new Set(targetUsers.map(u => u.gsdh_id || u.name));
// 1. Remove bubbles not in target
// Filter bubbles in place
const keptBubbles = [];
for (const b of bubbles) {
if (!targetIds.has(b.id)) {
b.remove();
} else {
keptBubbles.push(b);
}
}
bubbles = keptBubbles;
// 2. Add new bubbles
const currentIds = new Set(bubbles.map(b => b.id));
for (const u of targetUsers) {
const uid = u.gsdh_id || u.name;
if (!currentIds.has(uid)) {
bubbles.push(new UserBubble(u));
}
}
// Re-arrange if grid
if (isGridMode) {
// Delay slightly to let new bubbles exist in DOM?
// arrangeGrid uses 'bubbles' array, so it's fine.
// We might want to call arrangeGrid immediately.
setTimeout(arrangeGrid, 50);
}
}
function arrangeGrid() {
// Simple Grid Algorithm
const count = bubbles.length;
if (count === 0) return;
const cols = Math.ceil(Math.sqrt(count * (width / height)));
const rows = Math.ceil(count / cols);
const cellW = width / cols;
const cellH = height / rows;
bubbles.forEach((bubble, index) => {
const col = index % cols;
const row = Math.floor(index / cols);
// Center in cell
const targetX = col * cellW + (cellW - CONFIG.bubbleSize) / 2;
const targetY = row * cellH + (cellH - CONFIG.bubbleHeight) / 2;
bubble.moveTo(targetX, targetY);
});
}
function startRotationTimer() {
if (rotationTimer) clearInterval(rotationTimer);
rotationTimer = setInterval(() => {
if (allUsers.length > CONFIG.maxDisplay) {
rotationIndex += CONFIG.maxDisplay;
if (rotationIndex >= allUsers.length) rotationIndex = 0;
updateDisplay();
}
}, CONFIG.rotationInterval);
}
// --- Animation Loop ---
function animate() {
if (!isGridMode) {
bubbles.forEach(b => b.float());
}
requestAnimationFrame(animate);
}
animate(); // Start loop
// --- Data Fetching ---
async function fetchData() {
try {
const res = await fetch('/api/wall/data');
const json = await res.json();
if (json.success) {
const fetchedUsers = json.data;
document.getElementById('checkin-count').textContent = fetchedUsers.length;
// Check if new data arrived
if (fetchedUsers.length !== allUsers.length) {
const oldCount = allUsers.length;
allUsers = fetchedUsers;
// If we have more users than before
if (allUsers.length > oldCount) {
// Strategy: If we are rotating, jump to the NEWEST users immediately
// The API returns users sorted by created_at DESC (Newest first).
// So new users are at index 0.
// Wait, API says: ORDER BY created_at DESC
// So new users are at the TOP (0).
// If we want to show new users immediately:
rotationIndex = 0; // Show the first batch (newest)
updateDisplay();
// Reset timer to keep this view for a full interval
startRotationTimer();
// Trigger spotlight for newest
if (currentIndex === 0 && allUsers.length > 0) {
// Maybe spotlight the very first one
updateCard(allUsers[0]);
// Restart spotlight loop
startSpotlight();
}
} else {
// Users removed? Just update
updateDisplay();
}
} else {
// Data didn't change count, but maybe content changed?
// For simplicity, assume count change triggers update.
// Or if it's the first load
if (bubbles.length === 0 && allUsers.length > 0) {
updateDisplay();
startRotationTimer();
startSpotlight();
}
}
}
} catch (e) {
console.error("Fetch error", e);
}
}
// --- Spotlight Logic ---
let spotlightInterval = null;
function updateCard(user) {
const card = document.getElementById('card');
card.classList.remove('active');
// Wait for fade out
setTimeout(() => {
if(document.getElementById('card-name')) document.getElementById('card-name').textContent = user.name;
if(document.getElementById('card-position')) document.getElementById('card-position').textContent = user.position || '';
if(document.getElementById('card-company')) document.getElementById('card-company').textContent = user.company_name || '嘉宾';
if(document.getElementById('card-avatar')) document.getElementById('card-avatar').textContent = user.name ? user.name.charAt(0) : '?';
const extraDiv = document.getElementById('card-extra');
if (extraDiv) {
if (SHOW_VISION && user.vision_2026) {
extraDiv.textContent = "🎯 " + user.vision_2026;
extraDiv.style.display = 'block';
} else if (SHOW_SCOPE && user.business_scope) {
extraDiv.textContent = user.business_scope;
extraDiv.style.display = 'block';
} else {
extraDiv.style.display = 'none';
}
}
card.classList.add('active');
}, 800);
}
function startSpotlight() {
if (spotlightInterval) clearInterval(spotlightInterval);
showNextUser();
spotlightInterval = setInterval(showNextUser, 8000);
}
function showNextUser() {
if (users.length === 0) return;
const user = users[currentIndex];
currentIndex = (currentIndex + 1) % users.length;
updateCard(user);
}
function showUserManual(user) {
// Pause auto-rotation
if (spotlightInterval) clearInterval(spotlightInterval);
// Show clicked user immediately
updateCard(user);
// Resume auto-rotation after 15 seconds
spotlightInterval = setTimeout(() => {
startSpotlight();
}, 15000);
}
// Initial Load
fetchData();
setInterval(fetchData, 5000); // Check every 5s
</script>
</body>
</html>