大屏
This commit is contained in:
@@ -0,0 +1,34 @@
|
|||||||
|
I have analyzed the current project structure and designed a "Check-in Large Screen" (签到大屏) solution that integrates seamlessly with your existing system.
|
||||||
|
|
||||||
|
### **Plan Overview**
|
||||||
|
|
||||||
|
I will implement a **"Galaxy Spotlight"** style large screen:
|
||||||
|
1. **Background (Danmaku/Floating Tags)**:
|
||||||
|
- `Business Scope` and short `Vision` keywords will float elegantly in the background like stars or drifting clouds.
|
||||||
|
- Uses the project's blue/cyan tech theme.
|
||||||
|
|
||||||
|
2. **Foreground (Spotlight Card)**:
|
||||||
|
- A central, animated card that cycles through checked-in guests.
|
||||||
|
- Displays: **Company Name** (Prominent), **Guest Name/Position**, and **Vision 2026**.
|
||||||
|
- Updates every 8-10 seconds to ensure everyone gets exposure.
|
||||||
|
|
||||||
|
### **Implementation Steps**
|
||||||
|
|
||||||
|
#### 1. Backend (`main.py`)
|
||||||
|
- Add a new route `/wall` to serve the large screen page.
|
||||||
|
- Add an API `/api/wall/data` to fetch approved check-in data (filtering out empty entries).
|
||||||
|
|
||||||
|
#### 2. Frontend (`templates/wall.html`)
|
||||||
|
- Create a new responsive HTML template.
|
||||||
|
- **Visuals**: Reusing the `radial-gradient` background and neon aesthetics from `index.html`.
|
||||||
|
- **Animation**:
|
||||||
|
- JS-based floating animation for background tags.
|
||||||
|
- CSS transitions for the central spotlight card.
|
||||||
|
|
||||||
|
#### 3. Admin Integration (`templates/admin.html`)
|
||||||
|
- Add a button "Open Large Screen" (打开签到大屏) in the Admin Dashboard for easy access.
|
||||||
|
|
||||||
|
### **Technical Details**
|
||||||
|
- **Tech Stack**: Native JS/CSS (no extra heavy libraries needed), integrated into the existing FastAPI app.
|
||||||
|
- **Data Source**: Reads directly from `checkin_info` table.
|
||||||
|
- **Compatibility**: Optimized for 1080p/4K displays (MacOS standard).
|
||||||
48
main.py
48
main.py
@@ -46,6 +46,16 @@ DEFAULT_CONFIG = {
|
|||||||
"position": {"label": "职务", "show": True, "required": False},
|
"position": {"label": "职务", "show": True, "required": False},
|
||||||
"business_scope": {"label": "公司主要经营 / 业务", "show": True, "required": False},
|
"business_scope": {"label": "公司主要经营 / 业务", "show": True, "required": False},
|
||||||
"vision_2026": {"label": "2026年业务愿景", "show": True, "required": False}
|
"vision_2026": {"label": "2026年业务愿景", "show": True, "required": False}
|
||||||
|
},
|
||||||
|
"wall_config": {
|
||||||
|
"show_fields": {
|
||||||
|
"name": True,
|
||||||
|
"company_name": True,
|
||||||
|
"position": True,
|
||||||
|
"vision_2026": True,
|
||||||
|
"business_scope": True
|
||||||
|
},
|
||||||
|
"bg_opacity": 0.3
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1055,6 +1065,44 @@ async def admin_page(request: Request):
|
|||||||
secret = os.getenv("ADD_USER_SECRET", "123quant-speed")
|
secret = os.getenv("ADD_USER_SECRET", "123quant-speed")
|
||||||
return templates.TemplateResponse("admin.html", {"request": request, "secret": secret})
|
return templates.TemplateResponse("admin.html", {"request": request, "secret": secret})
|
||||||
|
|
||||||
|
@app.get("/wall", response_class=HTMLResponse)
|
||||||
|
async def wall_page(request: Request):
|
||||||
|
"""
|
||||||
|
渲染签到大屏页面。
|
||||||
|
"""
|
||||||
|
global CONFIG
|
||||||
|
CONFIG = load_config()
|
||||||
|
return templates.TemplateResponse("wall.html", {"request": request, "config": CONFIG})
|
||||||
|
|
||||||
|
@app.get("/api/wall/data")
|
||||||
|
def get_wall_data():
|
||||||
|
"""
|
||||||
|
获取大屏所需的数据:
|
||||||
|
1. 所有已签到用户的 Company Name, Vision, Business Scope
|
||||||
|
2. 过滤掉空数据
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
conn = get_db_connection()
|
||||||
|
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||||
|
|
||||||
|
# 获取最新的签到数据(按时间倒序)
|
||||||
|
query = """
|
||||||
|
SELECT name, company_name, position, business_scope, vision_2026, social_point
|
||||||
|
FROM checkin_info
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
"""
|
||||||
|
cur.execute(query)
|
||||||
|
rows = cur.fetchall()
|
||||||
|
|
||||||
|
cur.close()
|
||||||
|
release_db_connection(conn)
|
||||||
|
|
||||||
|
return {"success": True, "data": rows}
|
||||||
|
except Exception as e:
|
||||||
|
if 'conn' in locals() and conn:
|
||||||
|
release_db_connection(conn)
|
||||||
|
return JSONResponse(content={"success": False, "message": str(e)}, status_code=500)
|
||||||
|
|
||||||
@app.get("/api/admin/config")
|
@app.get("/api/admin/config")
|
||||||
def get_config():
|
def get_config():
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -287,6 +287,14 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<h1>管理后台</h1>
|
<h1>管理后台</h1>
|
||||||
|
|
||||||
|
<div style="text-align: center; margin-bottom: 25px;">
|
||||||
|
<a href="/wall" target="_blank" style="text-decoration: none;">
|
||||||
|
<button class="btn-lg" style="background: linear-gradient(90deg, #6a11cb 0%, #2575fc 100%); width: auto; padding: 15px 40px; box-shadow: 0 0 20px rgba(37, 117, 252, 0.5);">
|
||||||
|
📺 打开签到大屏 (Large Screen)
|
||||||
|
</button>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>基本信息设置</h2>
|
<h2>基本信息设置</h2>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -384,6 +392,32 @@
|
|||||||
<button class="btn-success" onclick="testDbConnection()">测试连接</button>
|
<button class="btn-success" onclick="testDbConnection()">测试连接</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>大屏设置 (Wall Config)</h2>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>背景图片透明度 (0-1)</label>
|
||||||
|
<input type="number" id="wall_bg_opacity" step="0.1" min="0" max="1" value="0.3">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>显示字段</label>
|
||||||
|
<div class="checkbox-wrapper">
|
||||||
|
<input type="checkbox" id="wall_show_name"> <label for="wall_show_name">姓名</label>
|
||||||
|
</div>
|
||||||
|
<div class="checkbox-wrapper">
|
||||||
|
<input type="checkbox" id="wall_show_company"> <label for="wall_show_company">单位名称</label>
|
||||||
|
</div>
|
||||||
|
<div class="checkbox-wrapper">
|
||||||
|
<input type="checkbox" id="wall_show_position"> <label for="wall_show_position">职务</label>
|
||||||
|
</div>
|
||||||
|
<div class="checkbox-wrapper">
|
||||||
|
<input type="checkbox" id="wall_show_vision"> <label for="wall_show_vision">愿景 (Vision)</label>
|
||||||
|
</div>
|
||||||
|
<div class="checkbox-wrapper">
|
||||||
|
<input type="checkbox" id="wall_show_scope"> <label for="wall_show_scope">业务范围 (弹幕)</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>主题设置</h2>
|
<h2>主题设置</h2>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -516,6 +550,16 @@
|
|||||||
document.getElementById('db_user').value = config.db_user || '';
|
document.getElementById('db_user').value = config.db_user || '';
|
||||||
document.getElementById('db_password').value = config.db_password || '';
|
document.getElementById('db_password').value = config.db_password || '';
|
||||||
document.getElementById('db_name').value = config.db_name || '';
|
document.getElementById('db_name').value = config.db_name || '';
|
||||||
|
|
||||||
|
// Wall Config
|
||||||
|
const wc = config.wall_config || {};
|
||||||
|
const show = wc.show_fields || {};
|
||||||
|
document.getElementById('wall_bg_opacity').value = wc.bg_opacity !== undefined ? wc.bg_opacity : 0.3;
|
||||||
|
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_vision').checked = show.vision_2026 !== false;
|
||||||
|
document.getElementById('wall_show_scope').checked = show.business_scope !== false;
|
||||||
});
|
});
|
||||||
|
|
||||||
function renderFieldConfig(fieldConfig) {
|
function renderFieldConfig(fieldConfig) {
|
||||||
@@ -605,6 +649,18 @@
|
|||||||
// Field Config
|
// Field Config
|
||||||
field_config: getFieldConfig(),
|
field_config: getFieldConfig(),
|
||||||
|
|
||||||
|
// Wall Config
|
||||||
|
wall_config: {
|
||||||
|
bg_opacity: parseFloat(document.getElementById('wall_bg_opacity').value),
|
||||||
|
show_fields: {
|
||||||
|
name: document.getElementById('wall_show_name').checked,
|
||||||
|
company_name: document.getElementById('wall_show_company').checked,
|
||||||
|
position: document.getElementById('wall_show_position').checked,
|
||||||
|
vision_2026: document.getElementById('wall_show_vision').checked,
|
||||||
|
business_scope: document.getElementById('wall_show_scope').checked
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
// DB Config
|
// DB Config
|
||||||
db_host: document.getElementById('db_host').value,
|
db_host: document.getElementById('db_host').value,
|
||||||
db_port: document.getElementById('db_port').value,
|
db_port: document.getElementById('db_port').value,
|
||||||
|
|||||||
500
templates/wall.html
Normal file
500
templates/wall.html
Normal file
@@ -0,0 +1,500 @@
|
|||||||
|
<!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;
|
||||||
|
filter: blur(10px); /* Increased blur for cleaner look */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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: 80px;
|
||||||
|
height: 100px;
|
||||||
|
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;
|
||||||
|
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: 1.2rem;
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3);
|
||||||
|
margin-bottom: 5px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<div class="subtitle">{{ config.event_sub_title }}</div>
|
||||||
|
<h1>{{ config.event_title }}</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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
|
||||||
|
bubbleSize: 80, // Width of bubble
|
||||||
|
bubbleHeight: 100, // Height of bubble (including name)
|
||||||
|
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 users = [];
|
||||||
|
let bubbles = []; // Array of UserBubble instances
|
||||||
|
let currentIndex = 0;
|
||||||
|
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.element = document.createElement('div');
|
||||||
|
this.element.className = 'user-bubble';
|
||||||
|
|
||||||
|
// Content
|
||||||
|
const initial = user.name ? user.name.charAt(0) : '?';
|
||||||
|
this.element.innerHTML = `
|
||||||
|
<div class="bubble-avatar">${initial}</div>
|
||||||
|
<div class="bubble-name">${user.name || '嘉宾'}</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
container.appendChild(this.element);
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Logic: Layout Manager ---
|
||||||
|
function updateLayout() {
|
||||||
|
const count = bubbles.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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function arrangeGrid() {
|
||||||
|
// Simple Grid Algorithm
|
||||||
|
const count = bubbles.length;
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 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 newUsers = json.data;
|
||||||
|
document.getElementById('checkin-count').textContent = newUsers.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));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
console.error("Fetch error", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Spotlight Logic ---
|
||||||
|
function startSpotlight() {
|
||||||
|
showNextUser();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial Load
|
||||||
|
fetchData();
|
||||||
|
setInterval(fetchData, 5000); // Check every 5s
|
||||||
|
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user