自定义字段

This commit is contained in:
jeremygan2021
2026-03-26 20:15:10 +08:00
parent cc46eb03cd
commit 1b5faf00cc
4 changed files with 926 additions and 86 deletions

Binary file not shown.

184
main.py
View File

@@ -3,6 +3,7 @@ from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from fastapi.responses import HTMLResponse, JSONResponse
from pydantic import BaseModel
from contextlib import asynccontextmanager
import psycopg2
from psycopg2 import pool
from psycopg2.extras import RealDictCursor
@@ -13,6 +14,7 @@ import random
import uuid
import os
import json
import re
import shutil
import difflib
import time
@@ -117,13 +119,12 @@ CONFIG = load_config()
# SMS Verification Storage (In-memory)
SMS_CODES = {} # phone -> {code: str, expires_at: datetime}
app = FastAPI()
# 进程池全局变量
process_pool = None
@app.on_event("startup")
async def startup_event():
@asynccontextmanager
async def lifespan(app: FastAPI):
"""应用生命周期管理:启动时初始化进程池,关闭时释放资源"""
import sys
print(f"DEBUG: Running from {__file__}")
print(f"DEBUG: Python executable: {sys.executable}")
@@ -142,17 +143,19 @@ async def startup_event():
print(f"{route.path}")
print("=========================")
@app.post("/api/test-sms")
async def test_sms_simple():
return {"message": "Test SMS route is working"}
yield
@app.on_event("shutdown")
async def shutdown_event():
# 关闭时清理进程池
if process_pool:
process_pool.shutdown()
print("ProcessPoolExecutor shutdown")
app = FastAPI(lifespan=lifespan)
@app.post("/api/test-sms")
async def test_sms_simple():
return {"message": "Test SMS route is working"}
# Database connection parameters - Moved to CONFIG
# 商业领域同义词库 (Business Thesaurus) - 用于解决模糊语义匹配
@@ -1548,6 +1551,167 @@ def init_database():
conn.close()
return JSONResponse(content={"success": False, "message": str(e)}, status_code=500)
class FieldAddRequest(BaseModel):
"""添加字段请求模型"""
table: str # 'ticket' or 'checkin'
key: str # 字段标识
label: str # 显示名称
show: bool = True
required: bool = False
class FieldDeleteRequest(BaseModel):
"""删除字段请求模型"""
table: str # 'ticket' or 'checkin'
key: str # 字段标识
@app.post("/api/admin/field/add")
async def add_field(req: FieldAddRequest):
"""
添加自定义字段:
1. 在数据库表中添加新列
2. 在 config.json 中更新字段配置
"""
global CONFIG
try:
# 确定表名
table_name = 'gsdh_data' if req.table == 'ticket' else 'checkin_info'
# 验证字段标识格式
if not re.match(r'^[a-zA-Z_][a-zA-Z0-9_]*$', req.key):
return JSONResponse(
content={"success": False, "message": "字段标识格式不正确(只能包含字母、数字和下划线,不能以数字开头)"},
status_code=400
)
# 防止删除锁定字段
locked_fields = ['name', 'phone', 'new_id', 'gsdh_id']
if req.key.lower() in locked_fields:
return JSONResponse(
content={"success": False, "message": f"不能添加锁定字段 {req.key}"},
status_code=400
)
# 获取当前配置
CONFIG = load_config()
# 获取当前字段配置
field_config_key = 'ticket_field_config' if req.table == 'ticket' else 'checkin_field_config'
current_fields = CONFIG.get(field_config_key, {})
# 检查字段是否已存在
if req.key in current_fields:
return JSONResponse(
content={"success": False, "message": f"字段 {req.key} 已存在"},
status_code=400
)
# 在数据库中添加列
conn = get_db_connection()
cur = conn.cursor()
try:
# 添加新列(使用 TEXT 类型)
cur.execute(f'ALTER TABLE {table_name} ADD COLUMN IF NOT EXISTS {req.key} TEXT')
conn.commit()
except Exception as db_e:
conn.rollback()
cur.close()
release_db_connection(conn)
return JSONResponse(
content={"success": False, "message": f"数据库操作失败: {str(db_e)}"},
status_code=500
)
cur.close()
release_db_connection(conn)
# 更新配置
current_fields[req.key] = {
"label": req.label,
"show": req.show,
"required": req.required
}
CONFIG[field_config_key] = current_fields
save_config(CONFIG)
return {"success": True, "message": f"字段 '{req.label}' 添加成功"}
except Exception as e:
import traceback
traceback.print_exc()
return JSONResponse(content={"success": False, "message": str(e)}, status_code=500)
@app.post("/api/admin/field/delete")
async def delete_field(req: FieldDeleteRequest):
"""
删除自定义字段:
1. 从 config.json 中移除字段配置
2. 从数据库表中删除列
"""
global CONFIG
try:
# 确定表名
table_name = 'gsdh_data' if req.table == 'ticket' else 'checkin_info'
# 防止删除锁定字段
locked_fields = ['name', 'phone', 'new_id', 'gsdh_id']
if req.key.lower() in locked_fields:
return JSONResponse(
content={"success": False, "message": f"不能删除锁定字段 {req.key}"},
status_code=400
)
# 获取当前配置
CONFIG = load_config()
# 获取当前字段配置
field_config_key = 'ticket_field_config' if req.table == 'ticket' else 'checkin_field_config'
current_fields = CONFIG.get(field_config_key, {})
# 检查字段是否存在
if req.key not in current_fields:
return JSONResponse(
content={"success": False, "message": f"字段 {req.key} 不存在"},
status_code=400
)
# 获取字段标签(用于返回消息)
field_label = current_fields[req.key].get('label', req.key)
# 从数据库中删除列
conn = get_db_connection()
cur = conn.cursor()
try:
# 删除列
cur.execute(f'ALTER TABLE {table_name} DROP COLUMN IF EXISTS {req.key}')
conn.commit()
except Exception as db_e:
conn.rollback()
cur.close()
release_db_connection(conn)
return JSONResponse(
content={"success": False, "message": f"数据库操作失败: {str(db_e)}"},
status_code=500
)
cur.close()
release_db_connection(conn)
# 更新配置(移除字段)
del current_fields[req.key]
CONFIG[field_config_key] = current_fields
save_config(CONFIG)
return {"success": True, "message": f"字段 '{field_label}' 已删除"}
except Exception as e:
import traceback
traceback.print_exc()
return JSONResponse(content={"success": False, "message": str(e)}, status_code=500)
# ==========================================
# WeChat Pay V3 & Ticket Logic
# ==========================================

View File

@@ -303,6 +303,188 @@
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>
@@ -417,38 +599,80 @@
<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>
<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="ticket_field_config_body">
<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 -->
</tbody>
</table>
</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>
<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="checkin_field_config_body">
<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 -->
</tbody>
</table>
</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>
@@ -616,6 +840,19 @@
<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', () => {
@@ -747,21 +984,21 @@
document.getElementById('max_per_table').value = config.max_per_table || 10;
toggleSeatingInputs();
// Field Configs
renderFieldConfig('ticket_field_config_body', config.ticket_field_config || {
// 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}
}, ["name", "phone", "industry_company"]);
});
renderFieldConfig('checkin_field_config_body', config.checkin_field_config || {
renderCheckinFields(config.checkin_field_config || {
"name": {"label": "姓名", "show": true, "required": true},
"phone": {"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}
}, ["name", "phone", "company_name", "position", "business_scope", "vision_2026"]);
});
// DB Config
document.getElementById('db_host').value = config.db_host || '';
@@ -836,6 +1073,380 @@
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;
@@ -877,9 +1488,9 @@
total_tables: parseInt(document.getElementById('total_tables').value),
max_per_table: parseInt(document.getElementById('max_per_table').value),
// Field Config
ticket_field_config: getFieldConfig('ticket_field_config_body'),
checkin_field_config: getFieldConfig('checkin_field_config_body'),
// Field Config - 使用新版获取函数
ticket_field_config: getTicketFieldConfigFromUI(),
checkin_field_config: getCheckinFieldConfigFromUI(),
// Wall Config
wall_config: {

View File

@@ -261,26 +261,10 @@
</div>
<form id="paymentForm" onsubmit="handlePayment(event)">
{% set ordered_keys = ['name', 'phone', 'industry_company'] %}
{% for key in ordered_keys %}
{% set field = config.ticket_field_config.get(key) %}
{% if field and field.show %}
<div class="form-group">
<label>{{ field.label }}{% if field.required %} *{% endif %}</label>
{% if key == 'phone' %}
<input type="tel" id="{{ key }}" {% if field.required %}required{% endif %} placeholder="请输入{{ field.label }}" pattern="[0-9]{11}">
{% if config.enable_sms_verification %}
<div style="display: flex; gap: 10px; margin-top: 10px;">
<input type="text" id="verification_code" placeholder="请先发送验证码" style="flex: 1;" disabled>
<button type="button" id="send-code-btn" onclick="sendSmsCode()" style="width: auto; padding: 10px; font-size: 0.9rem; background: var(--secondary-color); margin-top: 0; border: none; border-radius: 8px; color: white; cursor: pointer; display: flex; align-items: center; justify-content: center; min-width: 100px;">发送验证码</button>
<!-- 动态渲染报名字段 -->
<div id="registration_fields">
<!-- Fields will be rendered dynamically by JavaScript -->
</div>
{% endif %}
{% else %}
<input type="text" id="{{ key }}" {% if field.required %}required{% endif %} placeholder="请输入{{ field.label }}">
{% endif %}
</div>
{% endif %}
{% endfor %}
<button type="submit" class="submit-btn" id="submitBtn">
立即支付报名
@@ -306,8 +290,73 @@
</div>
<script>
let pollTimer = null;
let isCodeVerified = false;
// 字段配置(从后端传入)
var ticketFieldConfig = {{ config.ticket_field_config | tojson | safe }};
var enableSmsVerification = {{ 'true' if config.enable_sms_verification else 'false' }};
// 字段渲染顺序(锁定字段优先)
var lockedFields = ['name', 'phone'];
// 页面加载完成后渲染字段
window.addEventListener('DOMContentLoaded', function() {
renderRegistrationFields();
});
function renderRegistrationFields() {
var container = document.getElementById('registration_fields');
if (!container) return;
container.innerHTML = '';
// 定义字段渲染顺序
var orderedKeys = Object.keys(ticketFieldConfig).sort(function(a, b) {
// 锁定字段优先
var aLocked = lockedFields.indexOf(a) !== -1;
var bLocked = lockedFields.indexOf(b) !== -1;
if (aLocked && !bLocked) return -1;
if (!aLocked && bLocked) return 1;
return 0;
});
// 按顺序渲染字段
orderedKeys.forEach(function(key) {
var field = ticketFieldConfig[key];
if (!field || !field.show) return;
var div = document.createElement('div');
div.className = 'form-group';
// 创建标签
var label = document.createElement('label');
label.textContent = field.label + (field.required ? ' *' : '');
// 创建输入框
var inputHtml = '';
if (key === 'phone') {
// 手机号特殊处理
inputHtml = '<input type="tel" id="' + key + '"' + (field.required ? ' required' : '') + ' placeholder="请输入' + field.label + '" pattern="[0-9]{11}">';
if (enableSmsVerification) {
inputHtml += '<div style="display: flex; gap: 10px; margin-top: 10px;">' +
'<input type="text" id="verification_code" placeholder="请先发送验证码" style="flex: 1;" disabled>' +
'<button type="button" id="send-code-btn" onclick="sendSmsCode()" style="width: auto; padding: 10px; font-size: 0.9rem; background: var(--secondary-color); margin-top: 0; border: none; border-radius: 8px; color: white; cursor: pointer; display: flex; align-items: center; justify-content: center; min-width: 100px;">发送验证码</button>' +
'</div>';
}
} else {
inputHtml = '<input type="text" id="' + key + '"' + (field.required ? ' required' : '') + ' placeholder="请输入' + field.label + '">';
}
div.appendChild(label);
div.insertAdjacentHTML('beforeend', inputHtml);
container.appendChild(div);
});
// 初始化按钮状态
updateSubmitButtonState();
}
var pollTimer = null;
var isCodeVerified = false;
function updateSubmitButtonState() {
const btn = document.getElementById('submitBtn');
@@ -391,21 +440,30 @@
async function handlePayment(e) {
e.preventDefault();
const btn = document.getElementById('submitBtn');
const msg = document.getElementById('message');
const originalText = "立即支付报名"; // Hardcoded or get from element
var btn = document.getElementById('submitBtn');
var msg = document.getElementById('message');
var originalText = "立即支付报名";
// Validate
const getVal = (id) => {
const el = document.getElementById(id);
// Validate - 收集所有动态字段
var getVal = function(id) {
var el = document.getElementById(id);
return el ? el.value.trim() : '';
};
const name = getVal('name');
const phone = getVal('phone');
// 验证必填字段
var hasError = false;
Object.keys(ticketFieldConfig).forEach(function(key) {
var field = ticketFieldConfig[key];
if (field && field.required) {
var value = getVal(key);
if (!value) {
hasError = true;
}
}
});
if (!name || !phone) {
showMessage('请填写必填项', 'error');
if (hasError) {
showMessage('请填写所有必填项', 'error');
return;
}
@@ -414,16 +472,23 @@
btn.innerHTML = '<span class="loading"></span> 处理中...';
msg.style.display = 'none';
const data = {
name: name,
phone: phone,
verification_code: getVal('verification_code'),
company_name: getVal('industry_company')
// 收集所有字段数据
var data = {
name: getVal('name'),
phone: getVal('phone'),
verification_code: getVal('verification_code')
};
// 添加所有动态字段
Object.keys(ticketFieldConfig).forEach(function(key) {
if (key !== 'name' && key !== 'phone') {
data[key] = getVal(key);
}
});
// Check Screen Width
const isWideScreen = window.innerWidth > 768;
const apiEndpoint = isWideScreen ? '/api/payment/native' : '/api/payment/h5';
var isWideScreen = window.innerWidth > 768;
var apiEndpoint = isWideScreen ? '/api/payment/native' : '/api/payment/h5';
try {
const res = await fetch(apiEndpoint, {