完善大屏泡泡效果
This commit is contained in:
1
.vercel/project.json
Normal file
1
.vercel/project.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"projectName":"trae_upload_7ilw"}
|
||||||
7
.vercelignore
Normal file
7
.vercelignore
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules
|
||||||
|
build
|
||||||
|
dist
|
||||||
|
.git
|
||||||
|
.trae
|
||||||
|
.log
|
||||||
|
.figma
|
||||||
12
config.json
12
config.json
@@ -44,6 +44,18 @@
|
|||||||
"required": false
|
"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_host": "6.6.6.86",
|
||||||
"db_port": "5432",
|
"db_port": "5432",
|
||||||
"db_user": "zhao",
|
"db_user": "zhao",
|
||||||
|
|||||||
13
main.py
13
main.py
@@ -48,6 +48,8 @@ DEFAULT_CONFIG = {
|
|||||||
"vision_2026": {"label": "2026年业务愿景", "show": True, "required": False}
|
"vision_2026": {"label": "2026年业务愿景", "show": True, "required": False}
|
||||||
},
|
},
|
||||||
"wall_config": {
|
"wall_config": {
|
||||||
|
"show_title": True,
|
||||||
|
"learn_more_url": "https://www.example.com",
|
||||||
"show_fields": {
|
"show_fields": {
|
||||||
"name": True,
|
"name": True,
|
||||||
"company_name": True,
|
"company_name": True,
|
||||||
@@ -67,6 +69,15 @@ def load_config():
|
|||||||
for key, value in DEFAULT_CONFIG.items():
|
for key, value in DEFAULT_CONFIG.items():
|
||||||
if key not in config:
|
if key not in config:
|
||||||
config[key] = value
|
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 config
|
||||||
return DEFAULT_CONFIG
|
return DEFAULT_CONFIG
|
||||||
|
|
||||||
@@ -1087,7 +1098,7 @@ def get_wall_data():
|
|||||||
|
|
||||||
# 获取最新的签到数据(按时间倒序)
|
# 获取最新的签到数据(按时间倒序)
|
||||||
query = """
|
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
|
FROM checkin_info
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -398,6 +398,13 @@
|
|||||||
<label>背景图片透明度 (0-1)</label>
|
<label>背景图片透明度 (0-1)</label>
|
||||||
<input type="number" id="wall_bg_opacity" step="0.1" min="0" max="1" value="0.3">
|
<input type="number" id="wall_bg_opacity" step="0.1" min="0" max="1" value="0.3">
|
||||||
</div>
|
</div>
|
||||||
|
<div class="checkbox-wrapper">
|
||||||
|
<input type="checkbox" id="wall_show_title"> <label for="wall_show_title">显示大屏标题</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>了解我们 URL (手机端底部按钮)</label>
|
||||||
|
<input type="text" id="wall_learn_more_url" placeholder="https://...">
|
||||||
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>显示字段</label>
|
<label>显示字段</label>
|
||||||
<div class="checkbox-wrapper">
|
<div class="checkbox-wrapper">
|
||||||
@@ -555,8 +562,9 @@
|
|||||||
const wc = config.wall_config || {};
|
const wc = config.wall_config || {};
|
||||||
const show = wc.show_fields || {};
|
const show = wc.show_fields || {};
|
||||||
document.getElementById('wall_bg_opacity').value = wc.bg_opacity !== undefined ? wc.bg_opacity : 0.3;
|
document.getElementById('wall_bg_opacity').value = wc.bg_opacity !== undefined ? wc.bg_opacity : 0.3;
|
||||||
|
document.getElementById('wall_show_title').checked = wc.show_title !== false;
|
||||||
|
document.getElementById('wall_learn_more_url').value = wc.learn_more_url || '';
|
||||||
document.getElementById('wall_show_name').checked = show.name !== false;
|
document.getElementById('wall_show_name').checked = show.name !== false;
|
||||||
document.getElementById('wall_show_company').checked = show.company_name !== false;
|
|
||||||
document.getElementById('wall_show_position').checked = show.position !== false;
|
document.getElementById('wall_show_position').checked = show.position !== false;
|
||||||
document.getElementById('wall_show_vision').checked = show.vision_2026 !== false;
|
document.getElementById('wall_show_vision').checked = show.vision_2026 !== false;
|
||||||
document.getElementById('wall_show_scope').checked = show.business_scope !== false;
|
document.getElementById('wall_show_scope').checked = show.business_scope !== false;
|
||||||
@@ -652,6 +660,8 @@
|
|||||||
// Wall Config
|
// Wall Config
|
||||||
wall_config: {
|
wall_config: {
|
||||||
bg_opacity: parseFloat(document.getElementById('wall_bg_opacity').value),
|
bg_opacity: parseFloat(document.getElementById('wall_bg_opacity').value),
|
||||||
|
show_title: document.getElementById('wall_show_title').checked,
|
||||||
|
learn_more_url: document.getElementById('wall_learn_more_url').value,
|
||||||
show_fields: {
|
show_fields: {
|
||||||
name: document.getElementById('wall_show_name').checked,
|
name: document.getElementById('wall_show_name').checked,
|
||||||
company_name: document.getElementById('wall_show_company').checked,
|
company_name: document.getElementById('wall_show_company').checked,
|
||||||
|
|||||||
@@ -487,7 +487,7 @@
|
|||||||
<label>请输入手机号码或姓名</label>
|
<label>请输入手机号码或姓名</label>
|
||||||
<input type="text" id="search-input" placeholder="例如:13800000000 或 张三">
|
<input type="text" id="search-input" placeholder="例如:13800000000 或 张三">
|
||||||
</div>
|
</div>
|
||||||
<button onclick="searchUser()" id="search-btn">查询</button>
|
<button onclick="searchUser()" id="search-btn">签到</button>
|
||||||
<div id="search-error" class="error-msg hidden"></div>
|
<div id="search-error" class="error-msg hidden"></div>
|
||||||
<div id="user-list" class="hidden" style="margin-top: 15px;"></div>
|
<div id="user-list" class="hidden" style="margin-top: 15px;"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -568,7 +568,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p style="margin-top: 20px;">请入座等待会议开始</p>
|
<p style="margin-top: 20px;">请入座等待会议开始</p>
|
||||||
<button onclick="resetFlow()" style="margin-top: 30px; background: transparent; border: 1px solid rgba(255,255,255,0.2);">返回首页</button>
|
<button id="success-btn" onclick="window.location.href='/wall'" style="margin-top: 30px; background: transparent; border: 1px solid rgba(255,255,255,0.2);">前往活动</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Step 4: Already Signed -->
|
<!-- Step 4: Already Signed -->
|
||||||
@@ -593,7 +593,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p style="margin-top: 20px;">请入座等待会议开始</p>
|
<p style="margin-top: 20px;">请入座等待会议开始</p>
|
||||||
<button onclick="resetFlow()" style="margin-top: 30px; background: transparent; border: 1px solid rgba(255,255,255,0.2);">返回首页</button>
|
<button id="signed-btn" onclick="window.location.href='/wall'" style="margin-top: 30px; background: transparent; border: 1px solid rgba(255,255,255,0.2);">前往活动</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -799,6 +799,7 @@
|
|||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
|
document.getElementById('success-btn').textContent = payload.name + '前往活动';
|
||||||
if (CONFIG.enable_seating !== false) {
|
if (CONFIG.enable_seating !== false) {
|
||||||
// Show assigned seat
|
// Show assigned seat
|
||||||
document.getElementById('seat-display').textContent = data.seat || "自由席";
|
document.getElementById('seat-display').textContent = data.seat || "自由席";
|
||||||
|
|||||||
@@ -43,7 +43,8 @@
|
|||||||
background-position: center;
|
background-position: center;
|
||||||
opacity: var(--bg-opacity);
|
opacity: var(--bg-opacity);
|
||||||
z-index: -3;
|
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 */
|
/* Subtle overlay to ensure text contrast */
|
||||||
@@ -104,15 +105,15 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 80px;
|
width: 60px;
|
||||||
height: 100px;
|
height: 60px; /* Reduced from 80px */
|
||||||
transition: transform 0.8s cubic-bezier(0.2, 0.8, 0.2, 1), opacity 0.5s ease;
|
transition: transform 0.8s cubic-bezier(0.2, 0.8, 0.2, 1), opacity 0.5s ease;
|
||||||
will-change: transform, left, top;
|
will-change: transform, left, top;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bubble-avatar {
|
.bubble-avatar {
|
||||||
width: 60px;
|
width: 100%; /* Fill container */
|
||||||
height: 60px;
|
height: 100%; /* Fill container */
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: linear-gradient(135deg, var(--secondary-color), var(--primary-color));
|
background: linear-gradient(135deg, var(--secondary-color), var(--primary-color));
|
||||||
border: 2px solid rgba(255, 255, 255, 0.8);
|
border: 2px solid rgba(255, 255, 255, 0.8);
|
||||||
@@ -120,11 +121,15 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
font-size: 1.2rem;
|
font-size: 0.9rem; /* Reduced font size */
|
||||||
color: #fff;
|
color: #fff;
|
||||||
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3);
|
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3);
|
||||||
margin-bottom: 5px;
|
margin-bottom: 0; /* Removed margin */
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
text-align: center;
|
||||||
|
padding: 2px;
|
||||||
|
line-height: 1.1;
|
||||||
|
word-break: break-all;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bubble-avatar img {
|
.bubble-avatar img {
|
||||||
@@ -236,13 +241,86 @@
|
|||||||
color: #aaa;
|
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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
{% set wc = config.wall_config if config.wall_config else {} %}
|
||||||
|
{% if wc.show_title != false %}
|
||||||
<header>
|
<header>
|
||||||
<div class="subtitle">{{ config.event_sub_title }}</div>
|
<div class="subtitle">{{ config.event_sub_title }}</div>
|
||||||
<h1>{{ config.event_title }}</h1>
|
<h1>{{ config.event_title }}</h1>
|
||||||
</header>
|
</header>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<div class="bg-layer"></div>
|
<div class="bg-layer"></div>
|
||||||
<div class="clean-overlay"></div>
|
<div class="clean-overlay"></div>
|
||||||
@@ -276,6 +354,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</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-counter">
|
||||||
<div class="stats-number" id="checkin-count">0</div>
|
<div class="stats-number" id="checkin-count">0</div>
|
||||||
<div class="stats-label">已签到嘉宾</div>
|
<div class="stats-label">已签到嘉宾</div>
|
||||||
@@ -285,8 +369,10 @@
|
|||||||
// --- Configuration ---
|
// --- Configuration ---
|
||||||
const CONFIG = {
|
const CONFIG = {
|
||||||
gridThreshold: 20, // Switch to grid when users > 20
|
gridThreshold: 20, // Switch to grid when users > 20
|
||||||
bubbleSize: 80, // Width of bubble
|
maxDisplay: 30, // Maximum bubbles to display at once
|
||||||
bubbleHeight: 100, // Height of bubble (including name)
|
rotationInterval: 10000, // Rotation interval in ms (10s)
|
||||||
|
bubbleSize: 60, // Width of bubble
|
||||||
|
bubbleHeight: 60, // Height of bubble
|
||||||
speed: 1.5, // Floating speed
|
speed: 1.5, // Floating speed
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -294,9 +380,12 @@
|
|||||||
const SHOW_SCOPE = {{ 'true' if show.business_scope != false else 'false' }};
|
const SHOW_SCOPE = {{ 'true' if show.business_scope != false else 'false' }};
|
||||||
|
|
||||||
// --- State ---
|
// --- State ---
|
||||||
let users = [];
|
let allUsers = []; // All fetched users
|
||||||
|
let displayedUsers = []; // Currently displayed users
|
||||||
let bubbles = []; // Array of UserBubble instances
|
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;
|
let isGridMode = false;
|
||||||
|
|
||||||
const container = document.getElementById('bubble-container');
|
const container = document.getElementById('bubble-container');
|
||||||
@@ -313,18 +402,37 @@
|
|||||||
class UserBubble {
|
class UserBubble {
|
||||||
constructor(user) {
|
constructor(user) {
|
||||||
this.user = user;
|
this.user = user;
|
||||||
|
this.id = user.gsdh_id || user.name + Math.random(); // Fallback ID
|
||||||
this.element = document.createElement('div');
|
this.element = document.createElement('div');
|
||||||
this.element.className = 'user-bubble';
|
this.element.className = 'user-bubble';
|
||||||
|
|
||||||
// Content
|
// 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 = `
|
this.element.innerHTML = `
|
||||||
<div class="bubble-avatar">${initial}</div>
|
<div class="bubble-avatar" style="font-size: ${fontSize}">${name}</div>
|
||||||
<div class="bubble-name">${user.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);
|
container.appendChild(this.element);
|
||||||
|
|
||||||
|
// Trigger reflow for transition
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
this.element.style.opacity = '1';
|
||||||
|
});
|
||||||
|
|
||||||
// Initial Position (Random)
|
// Initial Position (Random)
|
||||||
this.x = Math.random() * (width - CONFIG.bubbleSize);
|
this.x = Math.random() * (width - CONFIG.bubbleSize);
|
||||||
this.y = Math.random() * (height - CONFIG.bubbleHeight);
|
this.y = Math.random() * (height - CONFIG.bubbleHeight);
|
||||||
@@ -360,27 +468,100 @@
|
|||||||
this.y = targetY;
|
this.y = targetY;
|
||||||
this.updatePosition();
|
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 ---
|
// --- Logic: Layout & Display Manager ---
|
||||||
function updateLayout() {
|
|
||||||
const count = bubbles.length;
|
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;
|
const wasGrid = isGridMode;
|
||||||
isGridMode = count > CONFIG.gridThreshold;
|
isGridMode = count > CONFIG.gridThreshold;
|
||||||
|
|
||||||
if (isGridMode) {
|
if (isGridMode) {
|
||||||
arrangeGrid();
|
arrangeGrid();
|
||||||
} else {
|
} else if (wasGrid && !isGridMode) {
|
||||||
// If switching back to floating (unlikely but possible), ensure animation loop is running
|
// Switching back to float, ensure they have random velocities if needed
|
||||||
if (wasGrid) {
|
// But typically we don't go back from grid to float unless people leave.
|
||||||
// Re-randomize velocities if needed or just let them float from current pos
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
function arrangeGrid() {
|
||||||
// Simple Grid Algorithm
|
// Simple Grid Algorithm
|
||||||
const count = bubbles.length;
|
const count = bubbles.length;
|
||||||
|
if (count === 0) return;
|
||||||
|
|
||||||
const cols = Math.ceil(Math.sqrt(count * (width / height)));
|
const cols = Math.ceil(Math.sqrt(count * (width / height)));
|
||||||
const rows = Math.ceil(count / cols);
|
const rows = Math.ceil(count / cols);
|
||||||
|
|
||||||
@@ -399,6 +580,17 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 ---
|
// --- Animation Loop ---
|
||||||
function animate() {
|
function animate() {
|
||||||
if (!isGridMode) {
|
if (!isGridMode) {
|
||||||
@@ -414,38 +606,49 @@
|
|||||||
const res = await fetch('/api/wall/data');
|
const res = await fetch('/api/wall/data');
|
||||||
const json = await res.json();
|
const json = await res.json();
|
||||||
if (json.success) {
|
if (json.success) {
|
||||||
const newUsers = json.data;
|
const fetchedUsers = json.data;
|
||||||
document.getElementById('checkin-count').textContent = newUsers.length;
|
document.getElementById('checkin-count').textContent = fetchedUsers.length;
|
||||||
|
|
||||||
// Detect new users
|
// Check if new data arrived
|
||||||
// Assuming API returns all users. We check by ID or Name (assuming unique for now or just length)
|
if (fetchedUsers.length !== allUsers.length) {
|
||||||
// For simplicity, we just check if length increased.
|
const oldCount = allUsers.length;
|
||||||
// In production, we should map IDs.
|
allUsers = fetchedUsers;
|
||||||
|
|
||||||
if (newUsers.length > users.length) {
|
// If we have more users than before
|
||||||
// Add new bubbles
|
if (allUsers.length > oldCount) {
|
||||||
for (let i = users.length; i < newUsers.length; i++) {
|
// Strategy: If we are rotating, jump to the NEWEST users immediately
|
||||||
const u = newUsers[i];
|
// The API returns users sorted by created_at DESC (Newest first).
|
||||||
bubbles.push(new UserBubble(u));
|
// 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) {
|
} catch (e) {
|
||||||
@@ -454,41 +657,59 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- Spotlight Logic ---
|
// --- 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() {
|
function startSpotlight() {
|
||||||
|
if (spotlightInterval) clearInterval(spotlightInterval);
|
||||||
showNextUser();
|
showNextUser();
|
||||||
setInterval(showNextUser, 8000);
|
spotlightInterval = setInterval(showNextUser, 8000);
|
||||||
}
|
}
|
||||||
|
|
||||||
function showNextUser() {
|
function showNextUser() {
|
||||||
if (users.length === 0) return;
|
if (users.length === 0) return;
|
||||||
|
const user = users[currentIndex];
|
||||||
|
currentIndex = (currentIndex + 1) % users.length;
|
||||||
|
updateCard(user);
|
||||||
|
}
|
||||||
|
|
||||||
const card = document.getElementById('card');
|
function showUserManual(user) {
|
||||||
card.classList.remove('active');
|
// Pause auto-rotation
|
||||||
|
if (spotlightInterval) clearInterval(spotlightInterval);
|
||||||
|
|
||||||
setTimeout(() => {
|
// Show clicked user immediately
|
||||||
const user = users[currentIndex];
|
updateCard(user);
|
||||||
currentIndex = (currentIndex + 1) % users.length;
|
|
||||||
|
|
||||||
if(document.getElementById('card-name')) document.getElementById('card-name').textContent = user.name;
|
// Resume auto-rotation after 15 seconds
|
||||||
if(document.getElementById('card-position')) document.getElementById('card-position').textContent = user.position || '';
|
spotlightInterval = setTimeout(() => {
|
||||||
if(document.getElementById('card-company')) document.getElementById('card-company').textContent = user.company_name || '嘉宾';
|
startSpotlight();
|
||||||
if(document.getElementById('card-avatar')) document.getElementById('card-avatar').textContent = user.name ? user.name.charAt(0) : '?';
|
}, 15000);
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initial Load
|
// Initial Load
|
||||||
|
|||||||
1
vercel.json
Normal file
1
vercel.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"rewrites":[{"source":"/(.*)","destination":"/index.html"}]}
|
||||||
Reference in New Issue
Block a user