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

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>