diff --git a/.vercel/project.json b/.vercel/project.json
new file mode 100644
index 0000000..755df9c
--- /dev/null
+++ b/.vercel/project.json
@@ -0,0 +1 @@
+{"projectName":"trae_upload_7ilw"}
\ No newline at end of file
diff --git a/.vercelignore b/.vercelignore
new file mode 100644
index 0000000..a48e5d3
--- /dev/null
+++ b/.vercelignore
@@ -0,0 +1,7 @@
+node_modules
+build
+dist
+.git
+.trae
+.log
+.figma
\ No newline at end of file
diff --git a/config.json b/config.json
index 54018a8..4a5a27a 100644
--- a/config.json
+++ b/config.json
@@ -44,6 +44,18 @@
"required": false
}
},
+ "wall_config": {
+ "bg_opacity": 0.9,
+ "show_title": true,
+ "learn_more_url": "https://www.example.com",
+ "show_fields": {
+ "name": true,
+ "company_name": false,
+ "position": true,
+ "vision_2026": true,
+ "business_scope": true
+ }
+ },
"db_host": "6.6.6.86",
"db_port": "5432",
"db_user": "zhao",
diff --git a/main.py b/main.py
index d319a25..a91665d 100644
--- a/main.py
+++ b/main.py
@@ -48,6 +48,8 @@ DEFAULT_CONFIG = {
"vision_2026": {"label": "2026年业务愿景", "show": True, "required": False}
},
"wall_config": {
+ "show_title": True,
+ "learn_more_url": "https://www.example.com",
"show_fields": {
"name": True,
"company_name": True,
@@ -67,6 +69,15 @@ def load_config():
for key, value in DEFAULT_CONFIG.items():
if key not in config:
config[key] = value
+
+ # Deep merge for wall_config
+ if "wall_config" in config and isinstance(config["wall_config"], dict):
+ for k, v in DEFAULT_CONFIG["wall_config"].items():
+ if k not in config["wall_config"]:
+ config["wall_config"][k] = v
+ elif "wall_config" not in config:
+ config["wall_config"] = DEFAULT_CONFIG["wall_config"]
+
return config
return DEFAULT_CONFIG
@@ -1087,7 +1098,7 @@ def get_wall_data():
# 获取最新的签到数据(按时间倒序)
query = """
- SELECT name, company_name, position, business_scope, vision_2026, social_point
+ SELECT gsdh_id, name, company_name, position, business_scope, vision_2026, social_point
FROM checkin_info
ORDER BY created_at DESC
"""
diff --git a/templates/admin.html b/templates/admin.html
index 27d19f7..d95ba1f 100644
--- a/templates/admin.html
+++ b/templates/admin.html
@@ -398,6 +398,13 @@
+
+
+
+
+
+
+
@@ -568,7 +568,7 @@
请入座等待会议开始
-
+
@@ -593,7 +593,7 @@
请入座等待会议开始
-
+
@@ -799,6 +799,7 @@
const data = await response.json();
if (response.ok) {
+ document.getElementById('success-btn').textContent = payload.name + '前往活动';
if (CONFIG.enable_seating !== false) {
// Show assigned seat
document.getElementById('seat-display').textContent = data.seat || "自由席";
diff --git a/templates/wall.html b/templates/wall.html
index 1296c0b..60227d6 100644
--- a/templates/wall.html
+++ b/templates/wall.html
@@ -43,7 +43,8 @@
background-position: center;
opacity: var(--bg-opacity);
z-index: -3;
- filter: blur(10px); /* Increased blur for cleaner look */
+ /* 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 */
@@ -104,15 +105,15 @@
flex-direction: column;
align-items: center;
justify-content: center;
- width: 80px;
- height: 100px;
+ 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: 60px;
- height: 60px;
+ 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);
@@ -120,11 +121,15 @@
align-items: center;
justify-content: center;
font-weight: bold;
- font-size: 1.2rem;
+ font-size: 0.9rem; /* Reduced font size */
color: #fff;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3);
- margin-bottom: 5px;
+ margin-bottom: 0; /* Removed margin */
overflow: hidden;
+ text-align: center;
+ padding: 2px;
+ line-height: 1.1;
+ word-break: break-all;
}
.bubble-avatar img {
@@ -236,13 +241,86 @@
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;
+ }
+ }
+
+ {% set wc = config.wall_config if config.wall_config else {} %}
+ {% if wc.show_title != false %}
+ {% endif %}
@@ -276,6 +354,12 @@
+ {% if wc.learn_more_url %}
+
+ {% endif %}
+
0
已签到嘉宾
@@ -285,8 +369,10 @@
// --- Configuration ---
const CONFIG = {
gridThreshold: 20, // Switch to grid when users > 20
- bubbleSize: 80, // Width of bubble
- bubbleHeight: 100, // Height of bubble (including name)
+ 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
};
@@ -294,9 +380,12 @@
const SHOW_SCOPE = {{ 'true' if show.business_scope != false else 'false' }};
// --- State ---
- let users = [];
+ let allUsers = []; // All fetched users
+ let displayedUsers = []; // Currently displayed users
let bubbles = []; // Array of UserBubble instances
- let currentIndex = 0;
+ let currentIndex = 0; // For spotlight
+ let rotationIndex = 0; // For batch rotation
+ let rotationTimer = null;
let isGridMode = false;
const container = document.getElementById('bubble-container');
@@ -313,17 +402,36 @@
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 initial = user.name ? user.name.charAt(0) : '?';
+ 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 = `
-
${initial}
-
${user.name || '嘉宾'}
+
${name}
`;
+ // 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);
@@ -360,27 +468,100 @@
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 Manager ---
- function updateLayout() {
- const count = bubbles.length;
+ // --- 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 switching back to floating (unlikely but possible), ensure animation loop is running
- if (wasGrid) {
- // Re-randomize velocities if needed or just let them float from current pos
+ } 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);
@@ -398,6 +579,17 @@
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() {
@@ -414,38 +606,49 @@
const res = await fetch('/api/wall/data');
const json = await res.json();
if (json.success) {
- const newUsers = json.data;
- document.getElementById('checkin-count').textContent = newUsers.length;
+ const fetchedUsers = json.data;
+ document.getElementById('checkin-count').textContent = fetchedUsers.length;
- // Detect new users
- // Assuming API returns all users. We check by ID or Name (assuming unique for now or just length)
- // For simplicity, we just check if length increased.
- // In production, we should map IDs.
-
- if (newUsers.length > users.length) {
- // Add new bubbles
- for (let i = users.length; i < newUsers.length; i++) {
- const u = newUsers[i];
- bubbles.push(new UserBubble(u));
+ // 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();
}
-
- // Update local list
- users = newUsers;
-
- // Update Layout Mode
- updateLayout();
-
- // Trigger Spotlight for the newest user (or cycle)
- // If it's the first load, start the cycle loop
- if (currentIndex === 0 && users.length > 0) {
- startSpotlight();
- }
- } else if (users.length === 0 && newUsers.length > 0) {
- // First load
- users = newUsers;
- users.forEach(u => bubbles.push(new UserBubble(u)));
- updateLayout();
- startSpotlight();
}
}
} catch (e) {
@@ -454,41 +657,59 @@
}
// --- 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();
- setInterval(showNextUser, 8000);
+ spotlightInterval = setInterval(showNextUser, 8000);
}
function showNextUser() {
if (users.length === 0) return;
-
- const card = document.getElementById('card');
- card.classList.remove('active');
-
- setTimeout(() => {
- const user = users[currentIndex];
- currentIndex = (currentIndex + 1) % users.length;
-
- 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);
+ 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
diff --git a/vercel.json b/vercel.json
new file mode 100644
index 0000000..c6c779f
--- /dev/null
+++ b/vercel.json
@@ -0,0 +1 @@
+{"rewrites":[{"source":"/(.*)","destination":"/index.html"}]}
\ No newline at end of file