This commit is contained in:
jeremygan2021
2026-01-21 17:42:30 +08:00
parent cf65c52c82
commit b2772224c2
11 changed files with 1975 additions and 72 deletions

View File

@@ -254,7 +254,7 @@
<div class="container">
<header>
<img src="/static/image.jpg" alt="云南AI共生大会" class="header-img" onerror="this.style.display='none'">
<img src="{{ config.header_image }}" alt="{{ config.event_title }}" class="header-img" onerror="this.style.display='none'">
<h1>添加新嘉宾</h1>
</header>

704
templates/admin.html Normal file
View File

@@ -0,0 +1,704 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>管理后台 - 云南AI共生大会</title>
<style>
:root {
--primary-color: #00f2ff;
--secondary-color: #0066ff;
--bg-color: #050814;
--card-bg: rgba(12, 24, 50, 0.5);
--text-color: #ffffff;
--text-muted: #b0c4de;
--accent-glow: rgba(0, 242, 255, 0.6);
}
* {
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);
background-image:
radial-gradient(circle at 50% 0%, #1a3a75 0%, #050814 60%),
radial-gradient(circle at 85% 30%, rgba(0, 242, 255, 0.1) 0%, transparent 40%),
radial-gradient(circle at 15% 70%, rgba(0, 102, 255, 0.15) 0%, transparent 40%);
background-attachment: fixed;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
overflow-x: hidden;
padding: 20px;
}
.container {
width: 100%;
max-width: 800px;
position: relative;
z-index: 1;
}
h1 {
font-size: 2rem;
margin-bottom: 30px;
text-align: center;
background: linear-gradient(180deg, #ffffff 0%, #d0eaff 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
font-weight: 900;
text-shadow: 0 0 20px rgba(0, 242, 255, 0.4);
}
.card {
background: var(--card-bg);
border-radius: 16px;
padding: 30px;
margin-bottom: 25px;
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(0, 242, 255, 0.2);
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.5);
}
h2 {
color: #fff;
margin-bottom: 20px;
font-size: 1.2rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
padding-bottom: 10px;
}
.form-group {
margin-bottom: 20px;
}
label {
display: block;
margin-bottom: 8px;
color: var(--primary-color);
font-size: 0.9rem;
}
input[type="text"], input[type="password"], textarea, select, input[type="number"] {
width: 100%;
padding: 12px;
background: rgba(0, 0, 0, 0.4);
border: 1px solid rgba(0, 242, 255, 0.3);
border-radius: 8px;
color: white;
font-size: 1rem;
outline: none;
transition: all 0.3s;
}
input:focus, textarea:focus {
border-color: var(--primary-color);
box-shadow: 0 0 10px rgba(0, 242, 255, 0.3);
background: rgba(0, 0, 0, 0.6);
}
input[type="color"] {
height: 40px;
width: 100px;
padding: 0;
border: none;
background: transparent;
cursor: pointer;
}
button {
background: linear-gradient(90deg, #0051ff 0%, #00f2ff 100%);
color: white;
padding: 12px 24px;
border: none;
border-radius: 8px;
cursor: pointer;
font-size: 1rem;
font-weight: bold;
transition: all 0.3s;
box-shadow: 0 4px 15px rgba(0, 242, 255, 0.3);
}
button:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(0, 242, 255, 0.5);
}
/* Button variants */
.btn-success {
background: linear-gradient(90deg, #28a745 0%, #20c997 100%);
box-shadow: 0 4px 15px rgba(32, 201, 151, 0.35);
}
.btn-danger,
.danger-btn {
background: linear-gradient(90deg, #cc0000 0%, #ff4d4d 100%);
box-shadow: 0 4px 15px rgba(255, 0, 0, 0.3);
}
.btn-block {
width: 100%;
}
.btn-lg {
padding: 16px 28px;
font-size: 1.2rem;
box-shadow: 0 0 20px rgba(0, 242, 255, 0.4);
}
button:disabled {
opacity: 0.6;
cursor: not-allowed;
box-shadow: none;
}
.danger-zone {
border: 1px solid rgba(255, 50, 50, 0.5);
background: rgba(50, 0, 0, 0.3);
}
.danger-zone h2 {
color: #ff6b6b;
border-color: rgba(255, 50, 50, 0.3);
}
.btn-danger:hover,
.danger-btn:hover {
box-shadow: 0 6px 20px rgba(255, 0, 0, 0.5);
}
.preview-img {
max-width: 100%;
height: auto;
margin-top: 10px;
border-radius: 8px;
border: 1px solid rgba(0, 242, 255, 0.3);
}
#message {
position: fixed;
top: 20px;
right: 20px;
padding: 15px 25px;
border-radius: 8px;
color: white;
font-weight: bold;
display: none;
z-index: 1000;
backdrop-filter: blur(10px);
box-shadow: 0 4px 15px rgba(0,0,0,0.3);
}
.success {
background: rgba(40, 167, 69, 0.9);
border: 1px solid #28a745;
}
.error {
background: rgba(220, 53, 69, 0.9);
border: 1px solid #dc3545;
}
/* Password Overlay */
#password-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: var(--bg-color);
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
background-image: inherit;
}
.auth-card {
background: var(--card-bg);
padding: 40px;
border-radius: 16px;
text-align: center;
width: 90%;
max-width: 400px;
box-shadow: 0 0 40px rgba(0,0,0,0.5);
border: 1px solid rgba(0, 242, 255, 0.3);
backdrop-filter: blur(20px);
}
/* Password input group */
.input-group {
position: relative;
}
.input-group .toggle-btn {
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
padding: 6px 10px;
font-size: 0.9rem;
background: rgba(0,0,0,0.3);
border: 1px solid rgba(0, 242, 255, 0.3);
border-radius: 6px;
box-shadow: none;
}
.input-group .toggle-btn:hover {
background: rgba(0,0,0,0.5);
}
.checkbox-wrapper {
display: flex;
align-items: center;
margin-bottom: 20px;
}
.checkbox-wrapper input[type="checkbox"] {
width: 20px;
height: 20px;
margin-right: 10px;
accent-color: var(--primary-color);
}
.checkbox-wrapper label {
margin-bottom: 0;
font-size: 1rem;
color: white;
cursor: pointer;
}
</style>
</head>
<body>
<!-- Password Overlay -->
<div id="password-overlay">
<div class="auth-card">
<h2 style="border:none; text-align:center; font-size:1.5rem;">请输入访问密钥</h2>
<div class="input-group" style="margin-bottom: 20px;">
<input type="password" id="secret-input" placeholder="输入密钥">
<button class="toggle-btn" aria-label="显示/隐藏密钥" data-target="secret-input" onclick="togglePasswordVisibility(this)">显示</button>
</div>
<button onclick="checkSecret()">确认进入</button>
<p id="secret-error" style="color: #ff6b6b; margin-top: 15px; display: none;">密钥错误</p>
</div>
</div>
<div class="container">
<h1>管理后台</h1>
<div class="card">
<h2>基本信息设置</h2>
<div class="form-group">
<label>活动标题</label>
<input type="text" id="event_title">
</div>
<div class="form-group">
<label>副标题</label>
<input type="text" id="event_sub_title">
</div>
<div class="form-group">
<label>活动时间</label>
<input type="text" id="event_time">
</div>
<div class="form-group">
<label>活动地点</label>
<input type="text" id="event_location">
</div>
<div class="form-group">
<label>活动内容</label>
<textarea id="event_content" rows="4"></textarea>
</div>
</div>
<div class="card">
<h2>签到设置</h2>
<div class="checkbox-wrapper">
<input type="checkbox" id="enable_ticket_validation">
<label for="enable_ticket_validation">开启验票验证 (核对数据库与付款记录)</label>
</div>
<p style="font-size: 0.9em; color: var(--text-muted); margin-left: 30px;">
关闭后将跳过数据库核查,直接允许签到(若用户不存在将自动创建)。
</p>
</div>
<div class="card">
<h2>座位设置</h2>
<div class="checkbox-wrapper">
<input type="checkbox" id="enable_seating" onchange="toggleSeatingInputs()">
<label for="enable_seating">开启分桌功能</label>
</div>
<div id="seating_inputs">
<div class="form-group">
<label>总桌数</label>
<input type="number" id="total_tables" min="1">
</div>
<div class="form-group">
<label>每桌最大人数</label>
<input type="number" id="max_per_table" min="1">
</div>
</div>
</div>
<div class="card">
<h2>表单字段设置</h2>
<table style="width: 100%; border-collapse: collapse; color: white;">
<thead>
<tr style="border-bottom: 1px solid rgba(255,255,255,0.1); text-align: left;">
<th style="padding: 10px;">字段名称</th>
<th style="padding: 10px;">是否显示</th>
<th style="padding: 10px;">是否必填</th>
</tr>
</thead>
<tbody id="field_config_body">
<!-- Fields will be injected here -->
</tbody>
</table>
</div>
<div class="card">
<h2>数据库设置</h2>
<div class="form-group">
<label>数据库地址 (Host)</label>
<input type="text" id="db_host" placeholder="localhost">
</div>
<div class="form-group">
<label>端口 (Port)</label>
<input type="text" id="db_port" placeholder="5432">
</div>
<div class="form-group">
<label>用户名 (User)</label>
<input type="text" id="db_user">
</div>
<div class="form-group">
<label>密码 (Password)</label>
<div class="input-group">
<input type="password" id="db_password" placeholder="数据库密码">
<button class="toggle-btn" aria-label="显示/隐藏密码" data-target="db_password" onclick="togglePasswordVisibility(this)">显示</button>
</div>
</div>
<div class="form-group">
<label>数据库名 (Database Name)</label>
<input type="text" id="db_name">
</div>
<button class="btn-success" onclick="testDbConnection()">测试连接</button>
</div>
<div class="card">
<h2>主题设置</h2>
<div class="form-group">
<label>主色调 (Primary Color)</label>
<input type="color" id="primary_color">
</div>
<div class="form-group">
<label>次色调 (Secondary Color)</label>
<input type="color" id="secondary_color">
</div>
<div class="form-group">
<label>背景色 (Background Color)</label>
<input type="color" id="bg_color">
</div>
</div>
<div class="card">
<h2>图片设置</h2>
<div class="form-group">
<label>顶部头图</label>
<input type="file" id="header_image_file" accept="image/*" style="padding: 10px 0;">
<img id="image_preview" class="preview-img" src="">
</div>
<button onclick="uploadImage()">上传图片</button>
</div>
<div style="margin-bottom: 25px; text-align: center;">
<button onclick="saveConfig()" class="btn-block btn-lg">保存所有设置</button>
</div>
<div class="card danger-zone">
<h2>危险区域</h2>
<p style="color: #ffcccc; margin-bottom: 15px;">此处操作不可逆,请谨慎操作。</p>
<div class="form-group">
<button class="btn-danger" onclick="resetDatabase()">重置/创建数据库</button>
<p style="font-size: 0.9em; color: #ffcccc; margin-top: 10px;">这将清空所有签到数据并重新创建表结构。</p>
</div>
</div>
<div id="message"></div>
</div>
<script>
// Use secret from backend
const ACCESS_SECRET = "{{ secret }}";
// Password Check Logic
function checkSecret() {
const input = document.getElementById('secret-input');
const error = document.getElementById('secret-error');
const overlay = document.getElementById('password-overlay');
if (input.value === ACCESS_SECRET) {
overlay.style.opacity = '0';
setTimeout(() => {
overlay.style.display = 'none';
}, 500);
} else {
error.style.display = 'block';
input.value = '';
input.focus();
}
}
document.getElementById('secret-input').addEventListener('keypress', function (e) {
if (e.key === 'Enter') {
checkSecret();
}
});
/**
* 切换密码输入框的可见性
* @param {HTMLButtonElement} btn 触发按钮(需携带 data-target 指向 input 的 id
*/
function togglePasswordVisibility(btn) {
const targetId = btn.dataset.target;
const input = document.getElementById(targetId);
if (!input) return;
const isHidden = input.type === 'password';
input.type = isHidden ? 'text' : 'password';
btn.textContent = isHidden ? '隐藏' : '显示';
}
function toggleSeatingInputs() {
const enabled = document.getElementById('enable_seating').checked;
const inputs = document.getElementById('seating_inputs');
if (enabled) {
inputs.style.display = 'block';
} else {
inputs.style.display = 'none';
}
}
// Load config on startup
fetch('/api/admin/config')
.then(res => res.json())
.then(config => {
document.getElementById('event_title').value = config.event_title;
document.getElementById('event_sub_title').value = config.event_sub_title;
document.getElementById('event_time').value = config.event_time;
document.getElementById('event_location').value = config.event_location;
document.getElementById('event_content').value = config.event_content;
document.getElementById('primary_color').value = config.primary_color;
document.getElementById('secondary_color').value = config.secondary_color;
document.getElementById('bg_color').value = config.bg_color;
document.getElementById('image_preview').src = config.header_image;
// Checkin Config
document.getElementById('enable_ticket_validation').checked = config.enable_ticket_validation !== false;
// Seating Config
document.getElementById('enable_seating').checked = config.enable_seating !== false; // Default true
document.getElementById('total_tables').value = config.total_tables || 14;
document.getElementById('max_per_table').value = config.max_per_table || 10;
toggleSeatingInputs();
// Field Config
renderFieldConfig(config.field_config || {
"name": {"label": "姓名", "show": true, "required": true},
"phone": {"label": "手机号码", "show": true, "required": true},
"company_name": {"label": "单位名称", "show": true, "required": false},
"position": {"label": "职务", "show": true, "required": false},
"business_scope": {"label": "公司主要经营 / 业务", "show": true, "required": false},
"vision_2026": {"label": "2026年业务愿景", "show": true, "required": false}
});
// DB Config
document.getElementById('db_host').value = config.db_host || '';
document.getElementById('db_port').value = config.db_port || '';
document.getElementById('db_user').value = config.db_user || '';
document.getElementById('db_password').value = config.db_password || '';
document.getElementById('db_name').value = config.db_name || '';
});
function renderFieldConfig(fieldConfig) {
const tbody = document.getElementById('field_config_body');
tbody.innerHTML = '';
// Order of fields
const orderedKeys = ["name", "phone", "company_name", "position", "business_scope", "vision_2026"];
orderedKeys.forEach(key => {
if (!fieldConfig[key]) return;
const field = fieldConfig[key];
const tr = document.createElement('tr');
tr.style.borderBottom = '1px solid rgba(255,255,255,0.05)';
// Name/Phone lock logic (can modify required/show but maybe should lock show for logic safety?
// User asked to set displayed/required, so I will allow it but maybe warn or just allow it.)
// Actually, name/phone are critical. I will disable the 'show' checkbox for them to prevent system break.
const isLocked = (key === 'name' || key === 'phone');
tr.innerHTML = `
<td style="padding: 10px;">${field.label} (${key})</td>
<td style="padding: 10px;">
<input type="checkbox" class="field-show" data-key="${key}" ${field.show ? 'checked' : ''} ${isLocked ? 'disabled' : ''}>
</td>
<td style="padding: 10px;">
<input type="checkbox" class="field-required" data-key="${key}" ${field.required ? 'checked' : ''} ${isLocked ? 'disabled' : ''}>
</td>
`;
tbody.appendChild(tr);
});
}
function getFieldConfig() {
const config = {};
const rows = document.querySelectorAll('#field_config_body tr');
rows.forEach(row => {
const showCb = row.querySelector('.field-show');
const reqCb = row.querySelector('.field-required');
const key = showCb.dataset.key;
// Retrieve label from somewhere or just hardcode/store in data attr.
// Simple way: text content
const labelText = row.cells[0].textContent.split(' (')[0];
config[key] = {
label: labelText,
show: showCb.checked,
required: reqCb.checked
};
});
// Ensure locked fields are true/true if disabled
if (config.name) { config.name.show = true; config.name.required = true; }
if (config.phone) { config.phone.show = true; config.phone.required = true; }
return config;
}
function showMessage(text, type) {
const msg = document.getElementById('message');
msg.textContent = text;
msg.className = type;
msg.style.display = 'block';
setTimeout(() => msg.style.display = 'none', 3000);
}
async function saveConfig() {
const config = {
event_title: document.getElementById('event_title').value,
event_sub_title: document.getElementById('event_sub_title').value,
event_time: document.getElementById('event_time').value,
event_location: document.getElementById('event_location').value,
event_content: document.getElementById('event_content').value,
primary_color: document.getElementById('primary_color').value,
secondary_color: document.getElementById('secondary_color').value,
bg_color: document.getElementById('bg_color').value,
header_image: document.getElementById('image_preview').src.replace(window.location.origin, ''),
// Checkin Config
enable_ticket_validation: document.getElementById('enable_ticket_validation').checked,
// Seating Config
enable_seating: document.getElementById('enable_seating').checked,
total_tables: parseInt(document.getElementById('total_tables').value),
max_per_table: parseInt(document.getElementById('max_per_table').value),
// Field Config
field_config: getFieldConfig(),
// DB Config
db_host: document.getElementById('db_host').value,
db_port: document.getElementById('db_port').value,
db_user: document.getElementById('db_user').value,
db_password: document.getElementById('db_password').value,
db_name: document.getElementById('db_name').value
};
try {
const res = await fetch('/api/admin/config', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(config)
});
if (res.ok) {
showMessage('设置已保存', 'success');
} else {
showMessage('保存失败', 'error');
}
} catch (e) {
showMessage('网络错误', 'error');
}
}
async function testDbConnection() {
const config = {
db_host: document.getElementById('db_host').value,
db_port: document.getElementById('db_port').value,
db_user: document.getElementById('db_user').value,
db_password: document.getElementById('db_password').value,
db_name: document.getElementById('db_name').value
};
try {
const res = await fetch('/api/admin/test-db', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(config)
});
const data = await res.json();
if (res.ok) {
showMessage('连接成功', 'success');
} else {
showMessage('连接失败: ' + data.message, 'error');
}
} catch (e) {
showMessage('网络错误', 'error');
}
}
async function uploadImage() {
const fileInput = document.getElementById('header_image_file');
if (!fileInput.files[0]) {
showMessage('请先选择图片', 'error');
return;
}
const formData = new FormData();
formData.append('file', fileInput.files[0]);
try {
const res = await fetch('/api/admin/upload', {
method: 'POST',
body: formData
});
const data = await res.json();
if (res.ok) {
document.getElementById('image_preview').src = data.url;
showMessage('图片上传成功,请记得点击保存设置', 'success');
} else {
showMessage('上传失败: ' + data.message, 'error');
}
} catch (e) {
showMessage('网络错误', 'error');
}
}
async function resetDatabase() {
if (!confirm('确定要重置数据库吗?所有数据将被清空!')) return;
try {
const res = await fetch('/api/admin/reset-db', {
method: 'POST'
});
const data = await res.json();
if (res.ok) {
showMessage('数据库已重置', 'success');
} else {
showMessage('重置失败: ' + data.message, 'error');
}
} catch (e) {
showMessage('网络错误', 'error');
}
}
</script>
</body>
</html>

622
templates/edit.html Normal file
View File

@@ -0,0 +1,622 @@
<!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.5); /* Glassy blue tint */
--text-color: #ffffff;
--text-muted: #b0c4de; /* Light blue-grey */
--accent-glow: rgba(0, 242, 255, 0.6);
}
* {
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);
/* Deep radial gradient mimicking the poster's atmosphere */
background-image:
radial-gradient(circle at 50% 0%, #1a3a75 0%, #050814 60%),
radial-gradient(circle at 85% 30%, rgba(0, 242, 255, 0.1) 0%, transparent 40%),
radial-gradient(circle at 15% 70%, rgba(0, 102, 255, 0.15) 0%, transparent 40%);
background-attachment: fixed;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
overflow-x: hidden;
}
/* Add a subtle grid or tech-line overlay if desired, but keep it clean for now */
body::before {
content: '';
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background:
linear-gradient(rgba(255,255,255,0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,0.03) 1px, transparent 1px);
background-size: 50px 50px;
pointer-events: none;
z-index: -1;
}
.container {
width: 100%;
max-width: 600px;
padding: 30px 20px;
position: relative;
z-index: 1;
}
header {
text-align: center;
margin-bottom: 40px;
padding-bottom: 20px;
border-bottom: 1px solid rgba(0, 242, 255, 0.2);
}
.header-img {
width: 100%;
max-height: 220px;
object-fit: cover;
border-radius: 12px;
margin-bottom: 25px;
box-shadow: 0 0 20px rgba(0, 242, 255, 0.2);
border: 1px solid rgba(0, 242, 255, 0.3);
}
h1 {
font-size: 2.4rem;
margin-bottom: 15px;
/* Poster style: Bold white/cyan gradient text */
background: linear-gradient(180deg, #ffffff 0%, #d0eaff 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
font-weight: 900;
letter-spacing: 1px;
text-shadow: 0 0 30px rgba(0, 242, 255, 0.4);
position: relative;
}
.card {
background: var(--card-bg);
border-radius: 16px;
padding: 30px;
margin-bottom: 25px;
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(0, 242, 255, 0.2);
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.5), inset 0 0 20px rgba(0, 242, 255, 0.05);
transition: transform 0.3s ease, box-shadow 0.3s ease;
}
.card:hover {
box-shadow: 0 8px 40px 0 rgba(0, 0, 0, 0.6), inset 0 0 20px rgba(0, 242, 255, 0.1);
}
h2 {
color: #fff;
text-align: center;
margin-bottom: 25px;
font-weight: 700;
letter-spacing: 1px;
}
.input-group {
margin-bottom: 25px;
}
label {
display: block;
margin-bottom: 10px;
font-size: 0.95rem;
color: var(--primary-color);
font-weight: 500;
}
input, textarea, select {
width: 100%;
padding: 14px 16px;
background: rgba(0, 0, 0, 0.4);
border: 1px solid rgba(0, 242, 255, 0.3);
border-radius: 8px;
color: white;
font-size: 1rem;
outline: none;
transition: all 0.3s;
box-shadow: inset 0 2px 5px rgba(0,0,0,0.3);
}
input:focus, textarea:focus {
border-color: var(--primary-color);
box-shadow: 0 0 15px rgba(0, 242, 255, 0.3), inset 0 2px 5px rgba(0,0,0,0.3);
background: rgba(0, 0, 0, 0.6);
}
/* Placeholder styling */
::placeholder {
color: rgba(255, 255, 255, 0.3);
}
button {
width: 100%;
padding: 16px;
border: none;
border-radius: 8px;
/* Gradient button */
background: linear-gradient(90deg, #0051ff 0%, #00f2ff 100%);
color: #fff;
font-size: 1.1rem;
font-weight: 700;
cursor: pointer;
transition: all 0.3s;
text-transform: uppercase;
letter-spacing: 1px;
box-shadow: 0 4px 15px rgba(0, 242, 255, 0.3);
text-shadow: 0 1px 2px rgba(0,0,0,0.2);
}
button:hover {
opacity: 1;
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(0, 242, 255, 0.5);
background: linear-gradient(90deg, #0040cc 0%, #00d0dd 100%);
}
button:active {
transform: translateY(0);
}
button:disabled {
background: #2a3b55;
cursor: not-allowed;
opacity: 0.6;
box-shadow: none;
transform: none;
}
.hidden {
display: none;
}
.error-msg {
color: #ff6b6b;
font-size: 0.9rem;
margin-top: 15px;
text-align: center;
background: rgba(255, 107, 107, 0.1);
padding: 10px;
border-radius: 6px;
border: 1px solid rgba(255, 107, 107, 0.3);
}
.success-msg {
color: #00ff88;
font-size: 1.8rem;
text-align: center;
margin: 20px 0 10px;
text-shadow: 0 0 10px rgba(0, 255, 136, 0.5);
}
/* Loading spinner */
.loader {
border: 3px solid rgba(255,255,255,0.1);
border-radius: 50%;
border-top: 3px solid var(--primary-color);
width: 20px;
height: 20px;
animation: spin 0.8s linear infinite;
display: inline-block;
vertical-align: middle;
margin-right: 10px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.user-select-item {
padding: 15px;
background: rgba(255,255,255,0.05);
margin-bottom: 8px;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
border: 1px solid transparent;
}
.user-select-item:hover {
background: rgba(0, 242, 255, 0.1);
border-color: var(--primary-color);
}
</style>
<style>
.tabs {
display: flex;
margin-bottom: 20px;
border-bottom: 2px solid rgba(255,255,255,0.1);
}
.tab {
padding: 10px 20px;
cursor: pointer;
opacity: 0.6;
transition: all 0.3s;
}
.tab.active {
opacity: 1;
border-bottom: 2px solid var(--primary-color);
margin-bottom: -2px;
color: var(--primary-color);
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
</style>
</head>
<body>
<div class="container">
<header>
<img src="{{ config.header_image }}" alt="{{ config.event_title }}" class="header-img" onerror="this.style.display='none'">
<div style="text-align: center; margin-bottom: 10px; color: var(--primary-color); font-size: 0.8rem; letter-spacing: 2px; font-weight: 600; opacity: 0.9;">
{{ config.event_sub_title }}
</div>
<h1>{{ config.event_title }}</h1>
</header>
<!-- Step 1: Search -->
<div id="step-search" class="card">
<h2 style="margin-bottom: 20px;">修改信息</h2>
<div class="input-group">
<label>请输入手机号码</label>
<input type="text" id="search-input" placeholder="例如13800000000">
</div>
<button onclick="searchUser()" id="search-btn">查询</button>
<div id="search-error" class="error-msg hidden"></div>
<div id="user-list" class="hidden" style="margin-top: 15px;"></div>
</div>
<!-- Step 2: Edit Info -->
<div id="step-form" class="card hidden">
<h2 style="margin-bottom: 20px;">编辑信息</h2>
<div class="tabs">
<div class="tab active" onclick="switchTab('signup')">报名信息</div>
<div class="tab" onclick="switchTab('checkin')">签到信息</div>
</div>
<form id="edit-form" onsubmit="submitEdit(event)">
<input type="hidden" id="gsdh-id">
<input type="hidden" id="current-is-signed" value="FALSE">
<!-- Tab: Signup Info -->
<div id="tab-signup" class="tab-content active">
<div class="input-group">
<label>姓名</label>
<input type="text" id="form-name" required>
</div>
<div class="input-group">
<label>手机号码 (不可修改)</label>
<input type="text" id="form-phone" required disabled style="opacity: 0.7;">
</div>
<div class="input-group">
<label>单位名称</label>
<input type="text" id="form-company" placeholder="点此输入">
</div>
<div class="input-group">
<label>费用 (Fee)</label>
<input type="text" id="form-fee" placeholder="例如0">
</div>
<div class="input-group">
<label>支付渠道</label>
<input type="text" id="form-payment" placeholder="例如wechat">
</div>
</div>
<!-- Tab: Checkin Info -->
<div id="tab-checkin" class="tab-content">
<div class="input-group">
<label>职务</label>
<input type="text" id="form-position" placeholder="点此输入">
</div>
<div class="input-group">
<label>签到状态</label>
<select id="form-is-signed">
<option value="FALSE">未签到</option>
<option value="TRUE">已签到</option>
</select>
</div>
<div class="input-group">
<label>座位号</label>
<input type="text" id="form-location" placeholder="例如第1桌 (仅已签到有效)">
</div>
<div class="input-group">
<label>公司主要经营 / 业务</label>
<textarea id="form-business" rows="2" placeholder="点此输入"></textarea>
</div>
<div class="input-group">
<label>2026年业务愿景</label>
<textarea id="form-vision" rows="3" placeholder="点此输入 (为了业务更好对接,最好表述清晰)"></textarea>
</div>
</div>
<button type="submit" id="submit-btn" style="margin-top: 20px;">保存修改</button>
<button type="button" onclick="resetFlow()" style="margin-top: 15px; background: transparent; border: 1px solid rgba(255,255,255,0.2);">取消</button>
</form>
<div id="submit-error" class="error-msg hidden"></div>
</div>
<!-- Step 3: Success -->
<div id="step-success" class="card hidden" style="text-align: center; padding: 40px 20px;">
<div style="font-size: 4rem; margin-bottom: 20px;"></div>
<h2 class="success-msg">修改成功!</h2>
<p style="color: var(--text-muted); margin-bottom: 20px;">您的信息已更新</p>
<button onclick="window.location.href='/'" style="margin-top: 30px;">返回首页</button>
<button onclick="resetFlow()" style="margin-top: 15px; background: transparent; border: 1px solid rgba(255,255,255,0.2);">继续修改</button>
</div>
</div>
<script>
async function searchUser() {
const query = document.getElementById('search-input').value.trim();
const btn = document.getElementById('search-btn');
const errorDiv = document.getElementById('search-error');
const listDiv = document.getElementById('user-list');
if (!query) {
errorDiv.textContent = "请输入查询内容";
errorDiv.classList.remove('hidden');
return;
}
// Reset UI
errorDiv.classList.add('hidden');
listDiv.classList.add('hidden');
listDiv.innerHTML = '';
btn.disabled = true;
btn.innerHTML = '<span class="loader"></span> 查询中...';
try {
// Reuse existing search API
const response = await fetch(`/api/search?query=${encodeURIComponent(query)}`);
const data = await response.json();
if (response.ok) {
if (data.multiple) {
// Handle multiple results
listDiv.classList.remove('hidden');
listDiv.innerHTML = '<p style="margin-bottom:10px; color:var(--text-muted)">查询到多位嘉宾,请选择:</p>';
data.users.forEach(user => {
const item = document.createElement('div');
item.className = 'user-select-item';
item.innerHTML = `<strong>${user.name}</strong> <span style="font-size:0.8em; opacity:0.7">(${user.phone})</span>`;
item.onclick = () => selectUser(user);
listDiv.appendChild(item);
});
} else if (data.found) {
// Single user found
// Need to handle both signed and unsigned cases
// api/search returns "user" object in both cases
// if signed, "checkin_info" might be needed.
// The /api/search response structure for signed user:
// { found: true, user: {...}, already_signed: true, seat: ..., tablemates: ... }
// Ideally we should fetch full details if already signed.
// Let's modify selectUser to handle this.
selectUser(data.user, data.already_signed);
} else {
errorDiv.textContent = "未找到该用户";
errorDiv.classList.remove('hidden');
}
} else {
errorDiv.textContent = data.message || "查询失败";
errorDiv.classList.remove('hidden');
}
} catch (err) {
errorDiv.textContent = "网络错误,请稍后重试";
errorDiv.classList.remove('hidden');
} finally {
btn.disabled = false;
btn.textContent = '查询';
}
}
async function selectUser(user, alreadySigned) {
// Populate form
document.getElementById('gsdh-id').value = user.new_id;
document.getElementById('form-name').value = user.name;
document.getElementById('form-phone').value = user.phone;
// If user is already signed, we should try to get their checkin info if possible.
// However, /api/search currently doesn't return the full checkin_info fields (like position, business_scope) in the 'user' object directly?
// Wait, looking at main.py:
// if signed: returns "user": user (from gsdh_data)
// It doesn't return checkin_info details in 'user'.
// We might need to fetch current details.
// But wait, if they are signed, /api/search returns { ... seat: ..., tablemates: ... }
// It doesn't return the fields we want to edit (position, vision_2026 etc).
// We might need to create a new endpoint to get full details OR rely on what we have.
// Actually, let's use the 'industry_company' from gsdh_data as initial value for company.
document.getElementById('form-company').value = user.industry_company || '';
// If we want to support editing existing checkin info, we need to fetch it.
// I'll add a call to get user details if already signed.
// We fetch details anyway to get signed status and location
try {
const res = await fetch(`/api/get-user-details?phone=${user.phone}`);
if (res.ok) {
const details = await res.json();
if (details.success) {
document.getElementById('form-company').value = details.data.company_name || user.industry_company || '';
document.getElementById('form-position').value = details.data.position || '';
document.getElementById('form-business').value = details.data.business_scope || '';
document.getElementById('form-vision').value = details.data.vision_2026 || '';
document.getElementById('form-is-signed').value = details.data.is_signed || 'FALSE';
document.getElementById('current-is-signed').value = details.data.is_signed || 'FALSE';
document.getElementById('form-location').value = details.data.location || '';
document.getElementById('form-fee').value = details.data.fee || '';
document.getElementById('form-payment').value = details.data.payment_channel || '';
// Update name if it's different in checkin info
if (details.data.name) document.getElementById('form-name').value = details.data.name;
}
}
} catch (e) {
console.error("Failed to fetch details", e);
}
// Switch steps
document.getElementById('step-search').classList.add('hidden');
document.getElementById('step-form').classList.remove('hidden');
// Default to signup tab
switchTab('signup');
}
function switchTab(tabName) {
// Update tabs
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
if (tabName === 'signup') {
document.querySelector('.tab:nth-child(1)').classList.add('active');
} else {
document.querySelector('.tab:nth-child(2)').classList.add('active');
}
// Update content
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
document.getElementById(`tab-${tabName}`).classList.add('active');
}
async function submitEdit(e) {
e.preventDefault();
const btn = document.getElementById('submit-btn');
const errorDiv = document.getElementById('submit-error');
btn.disabled = true;
btn.innerHTML = '<span class="loader"></span> 保存中...';
errorDiv.classList.add('hidden');
const payload = {
gsdh_id: document.getElementById('gsdh-id').value,
name: document.getElementById('form-name').value,
phone: document.getElementById('form-phone').value,
company_name: document.getElementById('form-company').value,
position: document.getElementById('form-position').value,
business_scope: document.getElementById('form-business').value,
vision_2026: document.getElementById('form-vision').value,
is_signed: document.getElementById('form-is-signed').value,
location: document.getElementById('form-location').value,
fee: document.getElementById('form-fee').value,
payment_channel: document.getElementById('form-payment').value
};
const currentSigned = document.getElementById('current-is-signed').value || 'FALSE';
const desiredSigned = payload.is_signed || 'FALSE';
try {
let checkinSeat = null;
if (currentSigned !== desiredSigned) {
if (desiredSigned === 'TRUE') {
// 执行签到(创建签到记录并分配座位)
const resCheckin = await fetch('/api/checkin', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const dataCheckin = await resCheckin.json();
if (!(resCheckin.ok && dataCheckin.success)) {
throw new Error(dataCheckin.message || '签到失败');
}
checkinSeat = dataCheckin.seat || null;
// 更新当前状态缓存
document.getElementById('current-is-signed').value = 'TRUE';
document.getElementById('form-is-signed').value = 'TRUE';
} else {
// 取消签到(删除签到记录并置为未签到)
const resUncheck = await fetch('/api/uncheckin', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
gsdh_id: payload.gsdh_id,
name: payload.name,
company_name: payload.company_name
})
});
const dataUncheck = await resUncheck.json();
if (!(resUncheck.ok && dataUncheck.success)) {
throw new Error(dataUncheck.message || '取消签到失败');
}
// 更新当前状态缓存
document.getElementById('current-is-signed').value = 'FALSE';
document.getElementById('form-is-signed').value = 'FALSE';
}
}
// 覆盖更新其余信息(不新增)
const payloadForUpdate = { ...payload };
if (desiredSigned === 'TRUE') {
// 避免覆盖空座位:若表单未填座位,则保留签到自动分配的座位
if (!payloadForUpdate.location && checkinSeat) {
payloadForUpdate.location = checkinSeat;
}
}
const response = await fetch('/api/update-user', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payloadForUpdate)
});
const data = await response.json();
if (response.ok && data.success) {
document.getElementById('step-form').classList.add('hidden');
document.getElementById('step-success').classList.remove('hidden');
} else {
errorDiv.textContent = data.message || "保存失败";
errorDiv.classList.remove('hidden');
}
} catch (err) {
errorDiv.textContent = "网络错误,请稍后重试";
errorDiv.classList.remove('hidden');
} finally {
btn.disabled = false;
btn.textContent = '保存修改';
}
}
function resetFlow() {
document.getElementById('step-success').classList.add('hidden');
document.getElementById('step-form').classList.add('hidden');
document.getElementById('step-search').classList.remove('hidden');
document.getElementById('search-input').value = '';
document.getElementById('user-list').innerHTML = '';
document.getElementById('edit-form').reset();
}
</script>
</body>
</html>

View File

@@ -3,12 +3,12 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>云南AI共生大会 - 签到</title>
<title>{{ config.event_title }} - 签到</title>
<style>
:root {
--primary-color: #00f2ff; /* Cyan from the AI sphere/text */
--secondary-color: #0066ff; /* Rich blue */
--bg-color: #050814; /* Deep space blue/black */
--primary-color: {{ config.primary_color }};
--secondary-color: {{ config.secondary_color }};
--bg-color: {{ config.bg_color }};
--card-bg: rgba(12, 24, 50, 0.5); /* Glassy blue tint */
--text-color: #ffffff;
--text-muted: #b0c4de; /* Light blue-grey */
@@ -468,15 +468,15 @@
<body>
<div class="container">
<header>
<img src="/static/image.jpg" alt="云南AI共生大会" class="header-img" onerror="this.style.display='none'">
<img src="{{ config.header_image }}" alt="{{ config.event_title }}" class="header-img" onerror="this.style.display='none'">
<div style="text-align: center; margin-bottom: 10px; color: var(--primary-color); font-size: 0.8rem; letter-spacing: 2px; font-weight: 600; opacity: 0.9;">
2026 INTELLIGENT LEADERSHIP • AI SYMBIOSIS
{{ config.event_sub_title }}
</div>
<h1>云南AI共生大会</h1>
<h1>{{ config.event_title }}</h1>
<div class="event-info">
<p>📅 <strong>时间:</strong>1月10日 下午 2:00</p>
<p>📍 <strong>地点:</strong>金鼎科技园18号平台B座一楼报告厅</p>
<p>💡 <strong>内容:</strong>邀请重磅大咖分享AI在各行业的企业应用及案例含深度交流环节。</p>
<p>📅 <strong>时间:</strong>{{ config.event_time }}</p>
<p>📍 <strong>地点:</strong>{{ config.event_location }}</p>
<p>💡 <strong>内容:</strong>{{ config.event_content }}</p>
</div>
</header>
@@ -513,25 +513,35 @@
<input type="text" id="form-phone" required>
</div>
{% set fc = config.field_config or {} %}
{% if fc.company_name and fc.company_name.show %}
<div class="input-group">
<label>单位名称</label>
<input type="text" id="form-company" placeholder="点此输入">
<label>{{ fc.company_name.label }}</label>
<input type="text" id="form-company" placeholder="点此输入" {{ 'required' if fc.company_name.required else '' }}>
</div>
{% endif %}
{% if fc.position and fc.position.show %}
<div class="input-group">
<label>职务</label>
<input type="text" id="form-position" placeholder="点此输入">
<label>{{ fc.position.label }}</label>
<input type="text" id="form-position" placeholder="点此输入" {{ 'required' if fc.position.required else '' }}>
</div>
{% endif %}
{% if fc.business_scope and fc.business_scope.show %}
<div class="input-group">
<label>公司主要经营 / 业务</label>
<textarea id="form-business" rows="2" placeholder="点此输入"></textarea>
<label>{{ fc.business_scope.label }}</label>
<textarea id="form-business" rows="2" placeholder="点此输入" {{ 'required' if fc.business_scope.required else '' }}></textarea>
</div>
{% endif %}
{% if fc.vision_2026 and fc.vision_2026.show %}
<div class="input-group">
<label>2026年业务愿景</label>
<textarea id="form-vision" rows="3" placeholder="点此输入 (为了业务更好对接,最好表述清晰)"></textarea>
<label>{{ fc.vision_2026.label }}</label>
<textarea id="form-vision" rows="3" placeholder="点此输入 (为了业务更好对接,最好表述清晰)" {{ 'required' if fc.vision_2026.required else '' }}></textarea>
</div>
{% endif %}
<input type="hidden" id="form-location" value="">
@@ -544,7 +554,7 @@
<div id="step-success" class="card hidden" style="text-align: center; padding: 40px 20px;">
<div style="font-size: 4rem; margin-bottom: 20px;">🎉</div>
<h2 class="success-msg">签到成功!</h2>
<p style="color: var(--text-muted); margin-bottom: 20px;">您的座位已分配</p>
<p id="success-seat-label" style="color: var(--text-muted); margin-bottom: 20px;">您的座位已分配</p>
<div id="seat-display" class="seat-info">
<!-- Seat number will be inserted here -->
@@ -569,7 +579,7 @@
<h3 id="signed-name" style="margin-bottom: 5px;"></h3>
<p id="signed-phone" style="opacity: 0.7;"></p>
</div>
<p style="color: var(--text-muted); margin-bottom: 20px;">您的座位是</p>
<p id="signed-seat-label" style="color: var(--text-muted); margin-bottom: 20px;">您的座位是</p>
<div id="signed-seat-display" class="seat-info">
<!-- Seat number will be inserted here -->
@@ -622,6 +632,8 @@
<script>
// Store selected user data
let currentUser = null;
// Inject Config
const CONFIG = {{ config | tojson }};
async function searchUser() {
const query = document.getElementById('search-input').value.trim();
@@ -648,6 +660,24 @@
const data = await response.json();
if (response.ok) {
if (data.allow_create) {
const u = data.user || {};
const tempId = 'TEMP_' + Date.now();
currentUser = { new_id: tempId };
document.getElementById('gsdh-id').value = tempId;
document.getElementById('form-name').value = u.name || (!/^\d+$/.test(query) ? query : '');
document.getElementById('form-phone').value = u.phone || (/^\d+$/.test(query) ? query : '');
document.getElementById('display-name').textContent = document.getElementById('form-name').value || '新嘉宾';
document.getElementById('display-phone').textContent = document.getElementById('form-phone').value;
document.getElementById('display-company').textContent = '现场注册';
document.getElementById('step-search').classList.add('hidden');
document.getElementById('step-form').classList.remove('hidden');
return;
}
if (data.multiple) {
// Handle multiple results
listDiv.classList.remove('hidden');
@@ -666,12 +696,22 @@
document.getElementById('signed-name').textContent = data.user.name;
document.getElementById('signed-phone').textContent = data.user.phone;
document.getElementById('signed-seat-display').textContent = data.seat || "自由席";
// Show tablemates if available
if (data.tablemates && data.tablemates.length > 0) {
renderTablemates(data.tablemates, 'signed-tablemates-list', 'signed-tablemates-container');
if (CONFIG.enable_seating !== false) {
document.getElementById('signed-seat-display').textContent = data.seat || "自由席";
document.getElementById('signed-seat-display').style.display = 'block';
document.getElementById('signed-seat-label').textContent = "您的座位是";
document.getElementById('signed-seat-label').style.display = 'block';
// Show tablemates if available
if (data.tablemates && data.tablemates.length > 0) {
renderTablemates(data.tablemates, 'signed-tablemates-list', 'signed-tablemates-container');
} else {
document.getElementById('signed-tablemates-container').classList.add('hidden');
}
} else {
document.getElementById('signed-seat-display').style.display = 'none';
document.getElementById('signed-seat-label').textContent = "签到成功,请自由入座";
document.getElementById('signed-tablemates-container').classList.add('hidden');
}
} else {
@@ -730,15 +770,21 @@
btn.innerHTML = '<span class="loader"></span> 提交中...';
errorDiv.classList.add('hidden');
// Helper to safe get value
const getVal = (id) => {
const el = document.getElementById(id);
return el ? el.value : '';
};
const payload = {
gsdh_id: document.getElementById('gsdh-id').value,
name: document.getElementById('form-name').value,
phone: document.getElementById('form-phone').value,
company_name: document.getElementById('form-company').value,
position: document.getElementById('form-position').value,
business_scope: document.getElementById('form-business').value,
vision_2026: document.getElementById('form-vision').value,
location: document.getElementById('form-location').value
gsdh_id: getVal('gsdh-id'),
name: getVal('form-name'),
phone: getVal('form-phone'),
company_name: getVal('form-company'),
position: getVal('form-position'),
business_scope: getVal('form-business'),
vision_2026: getVal('form-vision'),
location: getVal('form-location')
};
try {
@@ -753,13 +799,22 @@
const data = await response.json();
if (response.ok) {
// Show assigned seat
document.getElementById('seat-display').textContent = data.seat || "自由席";
// Show tablemates if available
if (data.tablemates && data.tablemates.length > 0) {
renderTablemates(data.tablemates, 'tablemates-list', 'tablemates-container');
if (CONFIG.enable_seating !== false) {
// Show assigned seat
document.getElementById('seat-display').textContent = data.seat || "自由席";
document.getElementById('seat-display').style.display = 'block';
document.getElementById('success-seat-label').textContent = "您的座位已分配";
document.getElementById('success-seat-label').style.display = 'block';
// Show tablemates if available
if (data.tablemates && data.tablemates.length > 0) {
renderTablemates(data.tablemates, 'tablemates-list', 'tablemates-container');
} else {
document.getElementById('tablemates-container').classList.add('hidden');
}
} else {
document.getElementById('seat-display').style.display = 'none';
document.getElementById('success-seat-label').textContent = "请自由入座";
document.getElementById('tablemates-container').classList.add('hidden');
}

View File

@@ -9,9 +9,9 @@
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
<style>
:root {
--primary-color: #00f2ff; /* Cyan from the AI sphere/text */
--secondary-color: #0066ff; /* Rich blue */
--bg-color: #050814; /* Deep space blue/black */
--primary-color: {{ config.primary_color }};
--secondary-color: {{ config.secondary_color }};
--bg-color: {{ config.bg_color }};
--card-bg: rgba(12, 24, 50, 0.5); /* Glassy blue tint */
--text-color: #ffffff;
--text-muted: #b0c4de; /* Light blue-grey */
@@ -172,7 +172,7 @@
<body>
<div id="app" class="container pb-20">
<!-- Header Image -->
<img src="/static/image.jpg" alt="Header Image" class="header-img" onerror="this.style.display='none'">
<img src="{{ config.header_image }}" alt="{{ config.event_title }}" class="header-img" onerror="this.style.display='none'">
<!-- Header Info -->
<div class="glass-card p-6 mb-6 text-center relative overflow-hidden">