Files
check_in/templates/admin.html
jeremygan2021 1b5faf00cc 自定义字段
2026-03-26 20:15:10 +08:00

1624 lines
64 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
user-select: none;
}
h2::after {
content: '▼';
font-size: 0.8em;
transition: transform 0.3s;
opacity: 0.7;
}
.card.collapsed h2::after {
transform: rotate(-90deg);
}
.card.collapsed h2 {
border-bottom: none;
margin-bottom: 0;
}
.card-content {
transition: all 0.3s ease-in-out;
max-height: 3000px; /* Ensure enough height for content */
opacity: 1;
overflow: hidden;
}
.card.collapsed .card-content {
max-height: 0;
opacity: 0;
margin: 0;
padding: 0;
}
.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;
}
/* 字段管理样式 */
.field-item {
display: flex;
align-items: center;
padding: 12px;
border-bottom: 1px solid rgba(255,255,255,0.05);
gap: 10px;
}
.field-item:last-child {
border-bottom: none;
}
.field-key {
min-width: 120px;
font-weight: bold;
color: var(--primary-color);
}
.field-key.locked {
color: #888;
}
.field-input {
flex: 1;
padding: 8px 12px;
background: rgba(0,0,0,0.4);
border: 1px solid rgba(0,242,255,0.3);
border-radius: 6px;
color: white;
font-size: 0.9rem;
}
.field-input:focus {
border-color: var(--primary-color);
outline: none;
box-shadow: 0 0 8px rgba(0,242,255,0.2);
}
.field-checkbox {
display: flex;
align-items: center;
gap: 5px;
white-space: nowrap;
}
.field-checkbox input {
width: 18px;
height: 18px;
accent-color: var(--primary-color);
}
.field-checkbox label {
margin: 0;
font-size: 0.85rem;
color: var(--text-muted);
}
.field-actions {
display: flex;
gap: 8px;
}
.btn-sm {
padding: 6px 12px;
font-size: 0.85rem;
border-radius: 6px;
}
.btn-danger-sm {
background: linear-gradient(90deg, #cc0000 0%, #ff4d4d 100%);
padding: 6px 12px;
font-size: 0.85rem;
border-radius: 6px;
box-shadow: 0 2px 8px rgba(255,0,0,0.3);
}
.btn-danger-sm:hover {
box-shadow: 0 4px 12px rgba(255,0,0,0.5);
}
.btn-success-sm {
background: linear-gradient(90deg, #28a745 0%, #20c997 100%);
padding: 6px 12px;
font-size: 0.85rem;
border-radius: 6px;
box-shadow: 0 2px 8px rgba(32,201,151,0.35);
}
.add-field-section {
margin-top: 15px;
padding: 15px;
background: rgba(0,242,255,0.05);
border-radius: 8px;
border: 1px dashed rgba(0,242,255,0.3);
}
.add-field-section h4 {
margin-bottom: 10px;
color: var(--primary-color);
font-size: 0.95rem;
}
.add-field-form {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
}
.add-field-form input {
padding: 8px 12px;
font-size: 0.9rem;
}
.add-field-form input[type="text"] {
flex: 1;
min-width: 100px;
}
.field-help {
font-size: 0.8em;
color: var(--text-muted);
margin-left: 5px;
}
.modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.7);
display: none;
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal-content {
background: var(--card-bg);
padding: 25px;
border-radius: 12px;
max-width: 400px;
width: 90%;
border: 1px solid rgba(0,242,255,0.3);
}
.modal-title {
margin-bottom: 15px;
font-size: 1.1rem;
}
.modal-buttons {
display: flex;
gap: 10px;
justify-content: flex-end;
margin-top: 20px;
}
.field-table-header {
display: flex;
padding: 10px;
background: rgba(0,0,0,0.3);
border-radius: 8px 8px 0 0;
font-weight: bold;
font-size: 0.85rem;
color: var(--text-muted);
}
.field-table-row {
display: flex;
padding: 10px;
align-items: center;
border-bottom: 1px solid rgba(255,255,255,0.05);
font-size: 0.9rem;
}
.field-table-row:hover {
background: rgba(255,255,255,0.03);
}
</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 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" style="padding: 20px;">
<h2 style="margin-bottom: 15px;">快捷导航</h2>
<div style="display: flex; gap: 10px; flex-wrap: wrap; justify-content: center;">
<a href="/add-user" style="text-decoration: none;">
<button style="background: linear-gradient(90deg, #28a745 0%, #20c997 100%);">
添加嘉宾
</button>
</a>
<a href="/edit" style="text-decoration: none;">
<button style="background: linear-gradient(90deg, #ffc107 0%, #fd7e14 100%);">
✏️ 编辑信息
</button>
</a>
<a href="/data-base" style="text-decoration: none;">
<button style="background: linear-gradient(90deg, #17a2b8 0%, #138496 100%);">
🗄️ 数据库管理
</button>
</a>
</div>
</div>
<div class="card">
<h2>基本信息设置</h2>
<div class="card-content">
<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>
<div class="card collapsed">
<h2>签到设置</h2>
<div class="card-content">
<div class="checkbox-wrapper">
<input type="checkbox" id="enable_ticket_validation">
<label for="enable_ticket_validation">开启验票验证 (核对数据库与付款记录)</label>
</div>
<div class="checkbox-wrapper">
<input type="checkbox" id="enable_sms_verification">
<label for="enable_sms_verification">开启手机号短信验证 (SMS Verification)</label>
</div>
<p style="font-size: 0.9em; color: var(--text-muted); margin-left: 30px;">
关闭后将跳过数据库核查,直接允许签到(若用户不存在将自动创建)。
</p>
</div>
</div>
<div class="card collapsed">
<h2>座位设置</h2>
<div class="card-content">
<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>
<div class="card collapsed">
<h2>报名输入字段设置 (Registration Fields)</h2>
<div class="card-content">
<p style="font-size: 0.9em; color: var(--text-muted); margin-bottom: 15px;">对应 gsdh_data 表 (ticket.html),可编辑显示名称、添加或删除字段</p>
<!-- 字段表头 -->
<div class="field-table-header" style="margin-bottom: 0;">
<div style="flex: 1;">字段标识 (Key)</div>
<div style="flex: 1.5;">显示名称</div>
<div style="width: 80px; text-align: center;">显示</div>
<div style="width: 80px; text-align: center;">必填</div>
<div style="width: 80px; text-align: center;">操作</div>
</div>
<!-- 字段列表容器 -->
<div id="ticket_fields_container" style="background: rgba(0,0,0,0.2); border-radius: 0 0 8px 8px; margin-bottom: 15px;">
<!-- Ticket Fields will be injected here -->
</div>
<!-- 添加新字段区域 -->
<div class="add-field-section">
<h4> 添加新字段</h4>
<div class="add-field-form">
<input type="text" id="new_ticket_field_key" placeholder="字段标识 (如: position)" style="flex: 1;">
<input type="text" id="new_ticket_field_label" placeholder="显示名称 (如: 职务)" style="flex: 1;">
<div class="checkbox-wrapper" style="margin: 0;">
<input type="checkbox" id="new_ticket_field_show" checked>
<label for="new_ticket_field_show">显示</label>
</div>
<div class="checkbox-wrapper" style="margin: 0;">
<input type="checkbox" id="new_ticket_field_required">
<label for="new_ticket_field_required">必填</label>
</div>
<button onclick="addTicketField()" class="btn-success btn-sm">添加</button>
</div>
<p class="field-help" style="margin-top: 8px;">💡 字段标识建议使用英文下划线格式,如 company_name系统会自动同步到数据库表结构</p>
</div>
</div>
</div>
<div class="card collapsed">
<h2>签到/大屏字段设置 (Check-in Fields)</h2>
<div class="card-content">
<p style="font-size: 0.9em; color: var(--text-muted); margin-bottom: 15px;">对应 checkin_info 表 (wall/checkin),可编辑显示名称、添加或删除字段</p>
<!-- 字段表头 -->
<div class="field-table-header" style="margin-bottom: 0;">
<div style="flex: 1;">字段标识 (Key)</div>
<div style="flex: 1.5;">显示名称</div>
<div style="width: 80px; text-align: center;">显示</div>
<div style="width: 80px; text-align: center;">必填</div>
<div style="width: 80px; text-align: center;">操作</div>
</div>
<!-- 字段列表容器 -->
<div id="checkin_fields_container" style="background: rgba(0,0,0,0.2); border-radius: 0 0 8px 8px; margin-bottom: 15px;">
<!-- Checkin Fields will be injected here -->
</div>
<!-- 添加新字段区域 -->
<div class="add-field-section">
<h4> 添加新字段</h4>
<div class="add-field-form">
<input type="text" id="new_checkin_field_key" placeholder="字段标识 (如: hobby)" style="flex: 1;">
<input type="text" id="new_checkin_field_label" placeholder="显示名称 (如: 兴趣爱好)" style="flex: 1;">
<div class="checkbox-wrapper" style="margin: 0;">
<input type="checkbox" id="new_checkin_field_show" checked>
<label for="new_checkin_field_show">显示</label>
</div>
<div class="checkbox-wrapper" style="margin: 0;">
<input type="checkbox" id="new_checkin_field_required">
<label for="new_checkin_field_required">必填</label>
</div>
<button onclick="addCheckinField()" class="btn-success btn-sm">添加</button>
</div>
<p class="field-help" style="margin-top: 8px;">💡 字段标识建议使用英文下划线格式,如 wechat_id系统会自动同步到数据库表结构</p>
</div>
</div>
</div>
<div class="card collapsed">
<h2>支付设置 (Payment)</h2>
<div class="card-content">
<div class="checkbox-wrapper">
<input type="checkbox" id="enable_payment">
<label for="enable_payment">开启付费报名 (Enable Paid Registration)</label>
</div>
<p style="font-size: 0.9em; color: var(--text-muted); margin-left: 30px; margin-bottom: 15px;">
开启后,<a href="/ticket" target="_blank" style="color: var(--primary-color);">/ticket</a> 页面将开放访问。
</p>
<div class="form-group">
<label>支付金额 (元)</label>
<input type="number" id="payment_amount" step="0.01" min="0.01">
</div>
<h3 style="color: #b0c4de; margin: 20px 0 15px; border-bottom: 1px solid rgba(255,255,255,0.1); padding-bottom: 5px; font-size: 1rem;">微信支付配置 (WeChat Pay V3)</h3>
<div class="form-group">
<label>AppID</label>
<input type="text" id="wx_appid" placeholder="wx...">
</div>
<div class="form-group">
<label>MchID (商户号)</label>
<input type="text" id="wx_mchid" placeholder="16...">
</div>
<div class="form-group">
<label>API V3 Key</label>
<div class="input-group">
<input type="password" id="wx_api_v3_key" placeholder="32位密钥">
<button class="toggle-btn" aria-label="显示/隐藏" data-target="wx_api_v3_key" onclick="togglePasswordVisibility(this)">显示</button>
</div>
</div>
<div class="form-group">
<label>Serial No (证书序列号)</label>
<input type="text" id="wx_serial_no" placeholder="证书序列号">
</div>
<div class="form-group">
<label>商户私钥路径 (Private Key Path)</label>
<input type="text" id="wx_private_key_path" placeholder="cert/apiclient_key.pem">
</div>
<div class="form-group">
<label>回调地址 (Notify URL)</label>
<input type="text" id="wx_notify_url" placeholder="https://your-domain.com/api/payment/notify">
</div>
</div>
</div>
<div class="card collapsed">
<h2>数据库设置</h2>
<div class="card-content">
<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>
<button class="btn-lg" style="margin-left: 10px; background: linear-gradient(90deg, #17a2b8 0%, #138496 100%);" onclick="initDatabase()">初始化/迁移结构</button>
<p style="font-size: 0.9em; color: var(--text-muted); margin-top: 10px;">
提示:切换到新数据库后,点击“初始化/迁移结构”可自动创建表结构(不会删除现有数据)。
</p>
</div>
</div>
<div class="card collapsed">
<h2>大屏设置 (Wall Config)</h2>
<div class="card-content">
<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="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">
<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>
<div class="card collapsed">
<h2>主题设置</h2>
<div class="card-content">
<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>
<div class="card collapsed">
<h2>图片设置</h2>
<div class="card-content">
<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>
<div style="margin-bottom: 25px; text-align: center;">
<button onclick="saveConfig()" class="btn-block btn-lg">保存所有设置</button>
</div>
<div class="card danger-zone collapsed">
<h2>危险区域</h2>
<div class="card-content">
<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>
<div id="message"></div>
</div>
<!-- 删除确认 Modal -->
<div id="deleteConfirmModal" class="modal-overlay">
<div class="modal-content">
<h3 class="modal-title">⚠️ 确认删除字段</h3>
<p id="deleteConfirmText" style="color: var(--text-muted); margin-bottom: 10px;"></p>
<p style="color: #ff6b6b; font-size: 0.9em; margin-bottom: 10px;">⚠️ 警告:此操作会同时删除数据库中对应的列!</p>
<div class="modal-buttons">
<button onclick="closeDeleteModal()" style="background: #555;">取消</button>
<button onclick="confirmDeleteField()" class="btn-danger">确认删除</button>
</div>
</div>
</div>
<script>
// Collapsible Cards Logic
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('.card h2').forEach(header => {
header.addEventListener('click', () => {
header.parentElement.classList.toggle('collapsed');
});
});
checkAuthStatus();
});
async function checkAuthStatus() {
try {
const res = await fetch('/api/admin/check-auth');
const data = await res.json();
if (data.authenticated) {
hideOverlay();
} else {
showOverlay();
}
} catch (e) {
showOverlay();
}
}
function showOverlay() {
document.getElementById('password-overlay').style.display = 'flex';
}
function hideOverlay() {
const overlay = document.getElementById('password-overlay');
overlay.style.opacity = '0';
setTimeout(() => {
overlay.style.display = 'none';
}, 500);
}
async function checkSecret() {
const input = document.getElementById('secret-input');
const error = document.getElementById('secret-error');
const password = input.value;
try {
const res = await fetch('/api/admin/login', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({password: password})
});
const data = await res.json();
if (data.success) {
error.style.display = 'none';
hideOverlay();
} else {
error.style.display = 'block';
input.value = '';
input.focus();
}
} catch (e) {
error.textContent = '网络错误';
error.style.display = 'block';
}
}
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;
document.getElementById('enable_sms_verification').checked = config.enable_sms_verification === true;
// Payment Config
document.getElementById('enable_payment').checked = config.enable_payment === true;
document.getElementById('payment_amount').value = config.payment_amount || 0.01;
const wx = config.wechat_pay_config || {};
document.getElementById('wx_appid').value = wx.appid || '';
document.getElementById('wx_mchid').value = wx.mchid || '';
document.getElementById('wx_api_v3_key').value = wx.api_v3_key || '';
document.getElementById('wx_serial_no').value = wx.serial_no || '';
document.getElementById('wx_private_key_path').value = wx.private_key_path || '';
document.getElementById('wx_notify_url').value = wx.notify_url || '';
// 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 Configs - 使用新版渲染函数
renderTicketFields(config.ticket_field_config || {
"name": {"label": "姓名", "show": true, "required": true},
"phone": {"label": "手机号码", "show": true, "required": true},
"industry_company": {"label": "行业名称/单位名称", "show": true, "required": true}
});
renderCheckinFields(config.checkin_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 || '';
// 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_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_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(tbodyId, fieldConfig, orderedKeys) {
const tbody = document.getElementById(tbodyId);
tbody.innerHTML = '';
// If orderedKeys not provided, use default order or Object.keys
if (!orderedKeys) {
orderedKeys = Object.keys(fieldConfig);
}
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
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(tbodyId) {
const config = {};
const rows = document.querySelectorAll(`#${tbodyId} tr`);
rows.forEach(row => {
const showCb = row.querySelector('.field-show');
const reqCb = row.querySelector('.field-required');
const key = showCb.dataset.key;
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 (config.name) { config.name.show = true; config.name.required = true; }
if (config.phone) { config.phone.show = true; config.phone.required = true; }
return config;
}
// ============ 新字段管理功能 ============
// 当前待删除字段信息
let pendingDeleteField = null;
/**
* 渲染报名字段列表(新版)
*/
function renderTicketFields(fieldConfig) {
const container = document.getElementById('ticket_fields_container');
container.innerHTML = '';
Object.keys(fieldConfig).forEach(key => {
const field = fieldConfig[key];
const isLocked = (key === 'name' || key === 'phone');
const row = document.createElement('div');
row.className = 'field-table-row';
row.innerHTML = `
<div style="flex: 1; font-weight: bold; color: ${isLocked ? '#888' : 'var(--primary-color)'}">
${key} ${isLocked ? '🔒' : ''}
</div>
<div style="flex: 1.5;">
<input type="text" class="field-input" data-field-key="${key}" value="${field.label}" ${isLocked ? 'readonly' : ''}>
</div>
<div style="width: 80px; text-align: center;">
<input type="checkbox" class="field-show-cb" data-key="${key}" ${field.show ? 'checked' : ''} ${isLocked ? 'disabled' : ''}>
</div>
<div style="width: 80px; text-align: center;">
<input type="checkbox" class="field-required-cb" data-key="${key}" ${field.required ? 'checked' : ''} ${isLocked ? 'disabled' : ''}>
</div>
<div style="width: 80px; text-align: center;">
${!isLocked ? `<button class="btn-danger-sm" onclick="deleteTicketField('${key}')" style="padding: 4px 8px; font-size: 0.8rem;">🗑️</button>` : '<span style="color: #555; font-size: 0.8rem;">锁定</span>'}
</div>
`;
container.appendChild(row);
});
}
/**
* 渲染签到字段列表(新版)
*/
function renderCheckinFields(fieldConfig) {
const container = document.getElementById('checkin_fields_container');
container.innerHTML = '';
Object.keys(fieldConfig).forEach(key => {
const field = fieldConfig[key];
const isLocked = (key === 'name' || key === 'phone');
const row = document.createElement('div');
row.className = 'field-table-row';
row.innerHTML = `
<div style="flex: 1; font-weight: bold; color: ${isLocked ? '#888' : 'var(--primary-color)'}">
${key} ${isLocked ? '🔒' : ''}
</div>
<div style="flex: 1.5;">
<input type="text" class="field-input" data-field-key="${key}" value="${field.label}" ${isLocked ? 'readonly' : ''}>
</div>
<div style="width: 80px; text-align: center;">
<input type="checkbox" class="field-show-cb" data-key="${key}" ${field.show ? 'checked' : ''} ${isLocked ? 'disabled' : ''}>
</div>
<div style="width: 80px; text-align: center;">
<input type="checkbox" class="field-required-cb" data-key="${key}" ${field.required ? 'checked' : ''} ${isLocked ? 'disabled' : ''}>
</div>
<div style="width: 80px; text-align: center;">
${!isLocked ? `<button class="btn-danger-sm" onclick="deleteCheckinField('${key}')" style="padding: 4px 8px; font-size: 0.8rem;">🗑️</button>` : '<span style="color: #555; font-size: 0.8rem;">锁定</span>'}
</div>
`;
container.appendChild(row);
});
}
/**
* 获取报名字段配置(从 UI
*/
function getTicketFieldConfigFromUI() {
const config = {};
const container = document.getElementById('ticket_fields_container');
const rows = container.querySelectorAll('.field-table-row');
rows.forEach(row => {
const keyDiv = row.querySelector('div:first-child');
const key = keyDiv.textContent.replace('🔒', '').trim();
const labelInput = row.querySelector('.field-input');
const showCb = row.querySelector('.field-show-cb');
const reqCb = row.querySelector('.field-required-cb');
config[key] = {
label: labelInput.value,
show: showCb.checked,
required: reqCb.checked
};
});
// 确保锁定字段
if (config.name) { config.name.show = true; config.name.required = true; }
if (config.phone) { config.phone.show = true; config.phone.required = true; }
return config;
}
/**
* 获取签到字段配置(从 UI
*/
function getCheckinFieldConfigFromUI() {
const config = {};
const container = document.getElementById('checkin_fields_container');
const rows = container.querySelectorAll('.field-table-row');
rows.forEach(row => {
const keyDiv = row.querySelector('div:first-child');
const key = keyDiv.textContent.replace('🔒', '').trim();
const labelInput = row.querySelector('.field-input');
const showCb = row.querySelector('.field-show-cb');
const reqCb = row.querySelector('.field-required-cb');
config[key] = {
label: labelInput.value,
show: showCb.checked,
required: reqCb.checked
};
});
// 确保锁定字段
if (config.name) { config.name.show = true; config.name.required = true; }
if (config.phone) { config.phone.show = true; config.phone.required = true; }
return config;
}
/**
* 添加报名字段
*/
async function addTicketField() {
const keyInput = document.getElementById('new_ticket_field_key');
const labelInput = document.getElementById('new_ticket_field_label');
const showCb = document.getElementById('new_ticket_field_show');
const requiredCb = document.getElementById('new_ticket_field_required');
const key = keyInput.value.trim();
const label = labelInput.value.trim();
if (!key || !label) {
showMessage('请填写字段标识和显示名称', 'error');
return;
}
// 验证 key 格式
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)) {
showMessage('字段标识格式不正确(只能包含字母、数字和下划线,不能以数字开头)', 'error');
return;
}
// 获取当前配置
const currentConfig = getTicketFieldConfigFromUI();
// 检查是否已存在
if (currentConfig[key]) {
showMessage('该字段标识已存在', 'error');
return;
}
try {
// 调用后端 API 添加字段
const res = await fetch('/api/admin/field/add', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
table: 'ticket',
key: key,
label: label,
show: showCb.checked,
required: requiredCb.checked
})
});
const data = await res.json();
if (res.ok) {
// 更新配置
currentConfig[key] = {
label: label,
show: showCb.checked,
required: requiredCb.checked
};
// 重新渲染
renderTicketFields(currentConfig);
// 清空输入框
keyInput.value = '';
labelInput.value = '';
showCb.checked = true;
requiredCb.checked = false;
showMessage(`字段 "${label}" 添加成功`, 'success');
} else {
showMessage(data.message || '添加字段失败', 'error');
}
} catch (e) {
showMessage('网络错误:' + e.message, 'error');
}
}
/**
* 添加签到字段
*/
async function addCheckinField() {
const keyInput = document.getElementById('new_checkin_field_key');
const labelInput = document.getElementById('new_checkin_field_label');
const showCb = document.getElementById('new_checkin_field_show');
const requiredCb = document.getElementById('new_checkin_field_required');
const key = keyInput.value.trim();
const label = labelInput.value.trim();
if (!key || !label) {
showMessage('请填写字段标识和显示名称', 'error');
return;
}
// 验证 key 格式
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)) {
showMessage('字段标识格式不正确(只能包含字母、数字和下划线,不能以数字开头)', 'error');
return;
}
// 获取当前配置
const currentConfig = getCheckinFieldConfigFromUI();
// 检查是否已存在
if (currentConfig[key]) {
showMessage('该字段标识已存在', 'error');
return;
}
try {
// 调用后端 API 添加字段
const res = await fetch('/api/admin/field/add', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
table: 'checkin',
key: key,
label: label,
show: showCb.checked,
required: requiredCb.checked
})
});
const data = await res.json();
if (res.ok) {
// 更新配置
currentConfig[key] = {
label: label,
show: showCb.checked,
required: requiredCb.checked
};
// 重新渲染
renderCheckinFields(currentConfig);
// 清空输入框
keyInput.value = '';
labelInput.value = '';
showCb.checked = true;
requiredCb.checked = false;
showMessage(`字段 "${label}" 添加成功`, 'success');
} else {
showMessage(data.message || '添加字段失败', 'error');
}
} catch (e) {
showMessage('网络错误:' + e.message, 'error');
}
}
/**
* 删除报名字段(显示确认框)
*/
function deleteTicketField(key) {
const currentConfig = getTicketFieldConfigFromUI();
const field = currentConfig[key];
if (!field) return;
pendingDeleteField = {
table: 'ticket',
key: key,
label: field.label,
container: 'ticket_fields_container',
configGetter: getTicketFieldConfigFromUI
};
document.getElementById('deleteConfirmText').textContent =
`确定要删除字段 "${field.label}" (${key}) 吗?`;
document.getElementById('deleteConfirmModal').style.display = 'flex';
}
/**
* 删除签到字段(显示确认框)
*/
function deleteCheckinField(key) {
const currentConfig = getCheckinFieldConfigFromUI();
const field = currentConfig[key];
if (!field) return;
pendingDeleteField = {
table: 'checkin',
key: key,
label: field.label,
container: 'checkin_fields_container',
configGetter: getCheckinFieldConfigFromUI
};
document.getElementById('deleteConfirmText').textContent =
`确定要删除字段 "${field.label}" (${key}) 吗?`;
document.getElementById('deleteConfirmModal').style.display = 'flex';
}
/**
* 关闭删除确认框
*/
function closeDeleteModal() {
document.getElementById('deleteConfirmModal').style.display = 'none';
pendingDeleteField = null;
}
/**
* 确认删除字段
*/
async function confirmDeleteField() {
if (!pendingDeleteField) return;
const { table, key } = pendingDeleteField;
try {
const res = await fetch('/api/admin/field/delete', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
table: table,
key: key
})
});
const data = await res.json();
if (res.ok) {
// 从 UI 中移除
const config = pendingDeleteField.configGetter();
delete config[key];
if (table === 'ticket') {
renderTicketFields(config);
} else {
renderCheckinFields(config);
}
closeDeleteModal();
showMessage(`字段 "${pendingDeleteField.label}" 已删除`, 'success');
} else {
showMessage(data.message || '删除字段失败', 'error');
}
} catch (e) {
showMessage('网络错误:' + e.message, 'error');
}
}
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,
enable_sms_verification: document.getElementById('enable_sms_verification').checked,
// Payment Config
enable_payment: document.getElementById('enable_payment').checked,
payment_amount: parseFloat(document.getElementById('payment_amount').value),
wechat_pay_config: {
appid: document.getElementById('wx_appid').value,
mchid: document.getElementById('wx_mchid').value,
api_v3_key: document.getElementById('wx_api_v3_key').value,
serial_no: document.getElementById('wx_serial_no').value,
private_key_path: document.getElementById('wx_private_key_path').value,
notify_url: document.getElementById('wx_notify_url').value
},
// 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 - 使用新版获取函数
ticket_field_config: getTicketFieldConfigFromUI(),
checkin_field_config: getCheckinFieldConfigFromUI(),
// Wall Config
wall_config: {
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: {
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_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 initDatabase() {
if (!confirm('确定要在当前配置的数据库中初始化/迁移表结构吗?\n此操作安全不会删除现有数据')) return;
try {
const res = await fetch('/api/admin/init-db', {
method: 'POST'
});
const data = await res.json();
if (res.ok) {
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>