Compare commits

..

13 Commits

Author SHA1 Message Date
jeremygan2021
da24458e5d new 2026-03-27 14:00:56 +08:00
jeremygan2021
84b243dc52 new 2026-03-27 13:27:47 +08:00
jeremygan2021
df261106da 1 2026-03-26 22:24:00 +08:00
jeremygan2021
3ab71269d7 fenzhuo 2026-03-26 20:27:59 +08:00
jeremygan2021
1b5faf00cc 自定义字段 2026-03-26 20:15:10 +08:00
jeremygan2021
cc46eb03cd actino 2026-03-20 16:28:04 +08:00
jeremygan2021
b902f70f7e actino 2026-03-20 16:26:09 +08:00
jeremygan2021
ccced584dc actino 2026-03-20 16:21:33 +08:00
jeremygan2021
7caff4d72d new 2026-03-20 16:18:58 +08:00
jeremygan2021
3bdb055b83 t 2026-03-20 15:55:02 +08:00
jeremygan2021
a770fbfd2a t 2026-03-20 15:47:12 +08:00
jeremygan2021
5396504c67 new
Some checks failed
Docker 构建与部署 / deploy (push) Has been cancelled
Docker 构建与部署 / build (push) Has been cancelled
2026-03-20 15:38:55 +08:00
jeremygan2021
97e743d031 new
Some checks failed
Docker 构建与部署 / build (push) Has been cancelled
Docker 构建与部署 / deploy (push) Has been cancelled
2026-03-20 15:38:02 +08:00
13 changed files with 3013 additions and 454 deletions

View File

@@ -1,120 +1,30 @@
name: Docker 构建与部署
name: 部署到服务器
on:
push:
branches: [main, master]
workflow_dispatch:
inputs:
arch:
description: '构建架构'
required: true
default: 'amd64'
type: choice
options:
- amd64
- arm64
- both
upload_only:
description: '仅上传已存在的tar文件'
required: false
default: 'false'
type: boolean
env:
IMAGE_NAME: checkin_sys
IMAGE_TAG: latest
CONTAINER_NAME: checkin_sys-container
LOCAL_PORT: 8800
CONTAINER_PORT: 8800
TARGET_DIR: /home/quant/data/dev/checkin
jobs:
build:
runs-on: ubuntu
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && !inputs.upload_only)
steps:
- name: 检出代码
uses: actions/checkout@v4
- name: 设置 Buildx
uses: docker/setup-buildx-action@v3
- name: 提取架构信息
id: arch
run: |
if [ "${{ github.event.inputs.arch }}" == "arm64" ]; then
echo "platform=linux/arm64" >> $GITHUB_OUTPUT
echo "arch_suffix=-arm64" >> $GITHUB_OUTPUT
elif [ "${{ github.event.inputs.arch }}" == "both" ]; then
echo "platforms=linux/amd64,linux/arm64" >> $GITHUB_OUTPUT
else
echo "platform=linux/amd64" >> $GITHUB_OUTPUT
echo "arch_suffix=-amd64" >> $GITHUB_OUTPUT
fi
- name: 构建 Docker 镜像
uses: docker/build-push-action@v5
with:
context: .
platforms: ${{ steps.arch.outputs.platforms || 'linux/amd64' }}
push: false
load: true
tags: ${{ env.IMAGE_NAME }}:${{ env.IMAGE_TAG }}
outputs: type=docker,dest=${{ env.IMAGE_NAME }}-${{ env.IMAGE_TAG }}${{ steps.arch.outputs.arch_suffix }}.tar
- name: 上传镜像 artifact
uses: actions/upload-artifact@v4
with:
name: docker-image-${{ steps.arch.outputs.arch_suffix || '-amd64' }}
path: ${{ env.IMAGE_NAME }}-${{ env.IMAGE_TAG }}${{ steps.arch.outputs.arch_suffix }}.tar
retention-days: 1
deploy:
runs-on: ubuntu-latest
needs: build
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu
steps:
- name: 检出代码
uses: actions/checkout@v4
- name: 下载镜像 artifact (AMD64)
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && (inputs.arch == 'amd64' || inputs.arch == 'both'))
uses: actions/download-artifact@v4
with:
name: docker-image--amd64
path: .
- name: 下载镜像 artifact (ARM64)
if: github.event_name == 'workflow_dispatch' && inputs.arch == 'both'
uses: actions/download-artifact@v4
with:
name: docker-image--arm64
path: .
- name: 安装 sshpass
run: |
sudo apt-get update
sudo apt-get install -y sshpass
- name: 上传到服务器
env:
SERVER_HOST: ${{ secrets.SERVER_HOST }}
SERVER_USER: ${{ secrets.SERVER_USER }}
SERVER_PASSWORD: ${{ secrets.SERVER_PASSWORD }}
SERVER_PORT: ${{ secrets.SERVER_PORT }}
TAR_FILE: ${{ env.IMAGE_NAME }}-${{ env.IMAGE_TAG }}-amd64.tar
run: |
sshpass -p "$SERVER_PASSWORD" scp -P "$SERVER_PORT" -o StrictHostKeyChecking=no "$TAR_FILE" "${SERVER_USER}@${SERVER_HOST}:/tmp/"
sshpass -p "$SERVER_PASSWORD" scp -P "$SERVER_PORT" -o StrictHostKeyChecking=no ".env" "${SERVER_USER}@${SERVER_HOST}:/tmp/"
- name: 在服务器上部署
env:
SERVER_HOST: ${{ secrets.SERVER_HOST }}
SERVER_USER: ${{ secrets.SERVER_USER }}
SERVER_PASSWORD: ${{ secrets.SERVER_PASSWORD }}
SERVER_PORT: ${{ secrets.SERVER_PORT }}
TAR_FILE: ${{ env.IMAGE_NAME }}-${{ env.IMAGE_TAG }}-amd64.tar
TARGET_DIR: ${{ env.TARGET_DIR }}
run: |
sshpass -p "$SERVER_PASSWORD" ssh -p "$SERVER_PORT" -o StrictHostKeyChecking=no "${SERVER_USER}@${SERVER_HOST}" << 'EOF'
set -e
@@ -123,62 +33,37 @@ jobs:
echo "$SERVER_PASSWORD" | sudo -S -p '' "$@"
}
echo "[INFO] 开始服务器端部署..."
# 检查并停止现有容器
if run_sudo docker ps -a --format 'table {{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
echo "[INFO] 发现现有容器 ${CONTAINER_NAME},正在停止并删除..."
run_sudo docker stop ${CONTAINER_NAME} || true
run_sudo docker rm ${CONTAINER_NAME} || true
fi
# 检查并删除现有镜像
if run_sudo docker images --format 'table {{.Repository}}:{{.Tag}}' | grep -q "^${IMAGE_NAME}:${IMAGE_TAG}$"; then
echo "[INFO] 发现现有镜像 ${IMAGE_NAME}:${IMAGE_TAG},正在删除..."
run_sudo docker rmi ${IMAGE_NAME}:${IMAGE_TAG} || true
fi
# 加载新镜像
echo "[INFO] 加载新镜像..."
run_sudo docker load -i /tmp/${TAR_FILE}
# 验证镜像是否加载成功
if run_sudo docker images | grep -q "${IMAGE_NAME}"; then
echo "[SUCCESS] 镜像加载成功"
else
echo "[ERROR] 镜像加载失败"
echo "===== 切换到目标目录: $TARGET_DIR ====="
cd $TARGET_DIR || {
echo "错误:目录 $TARGET_DIR 不存在!"
exit 1
}
echo -e "\n===== 停止并清理 Docker ====="
run_sudo docker compose down || true
echo -e "\n===== 拉取 Git 代码 ====="
if ! git pull; then
echo "警告Git pull 失败,尝试强制同步远程代码..."
git fetch --all
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
git reset --hard origin/$CURRENT_BRANCH
git pull
fi
# 启动新容器
echo "[INFO] 启动新容器..."
run_sudo docker run -d --network host --name ${CONTAINER_NAME} \
--env-file /tmp/.env \
-e DB_HOST=localhost \
-e DB_PORT=5432 \
${IMAGE_NAME}:${IMAGE_TAG}
echo -e "\n===== 配置环境变量 ====="
cat > backend/.env <<'ENVEOF'
ALIYUN_ACCESS_KEY_ID=${{ secrets.ALIYUN_ACCESS_KEY_ID }}
ALIYUN_ACCESS_KEY_SECRET=${{ secrets.ALIYUN_ACCESS_KEY_SECRET }}
ALIYUN_OSS_ENDPOINT=https://oss-cn-shanghai.aliyuncs.com
ALIYUN_OSS_BUCKET_NAME=tangledup-ai-staging
ALIYUN_OSS_INTERNAL_ENDPOINT=https://oss-cn-shanghai-internal.aliyuncs.com
ALIYUN_TINGWU_APP_KEY=${{ secrets.ALIYUN_TINGWU_APP_KEY }}
DASHSCOPE_API_KEY=${{ secrets.DASHSCOPE_API_KEY }}
ENVEOF
# 验证容器是否启动成功
if run_sudo docker ps | grep -q "${CONTAINER_NAME}"; then
echo "[SUCCESS] 容器启动成功"
echo "[INFO] 容器状态:"
run_sudo docker ps | grep "${CONTAINER_NAME}"
else
echo "[ERROR] 容器启动失败"
echo "[INFO] 查看容器日志:"
run_sudo docker logs ${CONTAINER_NAME}
exit 1
fi
echo -e "\n===== 启动 Docker 容器 ====="
run_sudo docker compose up -d --build
# 清理临时文件
echo "[INFO] 清理临时文件..."
rm -f /tmp/${TAR_FILE}
rm -f /tmp/.env
echo "[SUCCESS] 部署完成!"
echo "[INFO] 应用访问地址: http://${SERVER_HOST}:${LOCAL_PORT}"
echo -e "\n===== 操作完成!====="
EOF
- name: 清理本地临时文件
run: |
rm -f ${{ env.IMAGE_NAME }}-${{ env.IMAGE_TAG }}-*.tar

Binary file not shown.

Binary file not shown.

View File

@@ -1,16 +1,18 @@
{
"event_title": "AI共生大会",
"event_sub_title": "云南AI大会",
"event_time": "等待输入",
"event_location": "玉溪青花街三生咖啡酒吧",
"event_content": "等待输入",
"event_title": "面对面行业联合分享",
"event_sub_title": "3月特别期",
"event_time": "2026.3.27 19:30-22:00",
"event_location": "百里春秋文化空间二楼小剧场",
"event_content": "当增长不再靠规模,企业还能怎么活",
"primary_color": "#00f2ff",
"secondary_color": "#0066ff",
"bg_color": "#050814",
"header_image": "/static/image1.jpg",
"enable_ticket_validation": true,
"enable_sms_verification": true,
"enable_payment": true,
"enable_qrcode": false,
"qrcode_image": "/admin",
"enable_ticket_validation": false,
"enable_sms_verification": false,
"enable_payment": false,
"payment_amount": 0.01,
"wechat_pay_config": {
"appid": "wxdf2ca73e6c0929f0",
@@ -21,6 +23,7 @@
"notify_url": "https://event.quant-speed.com/ticket/finish/"
},
"enable_seating": false,
"seat_unit_name": "桌",
"total_tables": 14,
"max_per_table": 10,
"ticket_field_config": {
@@ -65,22 +68,19 @@
"label": "公司主要经营 / 业务",
"show": true,
"required": true
},
"vision_2026": {
"label": "2026年业务愿景",
"show": false,
"required": false
}
},
"wall_config": {
"bg_opacity": 0.3,
"show_title": true,
"learn_more_url": "",
"show_qrcode": true,
"show_seating": true,
"show_fields": {
"name": true,
"name": false,
"phone": false,
"company_name": false,
"position": true,
"vision_2026": true,
"business_scope": true
}
},

560
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) - 用于解决模糊语义匹配
@@ -938,19 +941,35 @@ def checkin_user(checkin_data: CheckinRequest):
new_real_id = str(max_id + 1)
# 2. Insert into gsdh_data first to satisfy Foreign Key
# 先检查 industry_company 列是否存在
cur.execute("""
SELECT column_name FROM information_schema.columns
WHERE table_name = 'gsdh_data' AND column_name = 'industry_company'
""")
has_industry_col = cur.fetchone() is not None
if has_industry_col:
insert_user_sql = """
INSERT INTO gsdh_data (new_id, name, phone, industry_company, fee, payment_channel, is_signed)
VALUES (%s, %s, %s, %s, '0', 'onsite_checkin', 'TRUE')
"""
# Use provided company name or default
industry_val = checkin_data.company_name or "现场注册"
cur.execute(insert_user_sql, (
new_real_id,
checkin_data.name,
checkin_data.phone,
industry_val
))
else:
insert_user_sql = """
INSERT INTO gsdh_data (new_id, name, phone, fee, payment_channel, is_signed)
VALUES (%s, %s, %s, '0', 'onsite_checkin', 'TRUE')
"""
cur.execute(insert_user_sql, (
new_real_id,
checkin_data.name,
checkin_data.phone
))
# Update ID for subsequent operations
final_gsdh_id = new_real_id
@@ -1210,6 +1229,14 @@ def add_user_api(user_data: AddUserRequest):
max_id = row[0] if row and row[0] is not None else 0
new_id = str(max_id + 1)
# 先检查 industry_company 列是否存在
cur.execute("""
SELECT column_name FROM information_schema.columns
WHERE table_name = 'gsdh_data' AND column_name = 'industry_company'
""")
has_industry_col = cur.fetchone() is not None
if has_industry_col:
insert_sql = """
INSERT INTO gsdh_data (new_id, name, phone, industry_company, fee, payment_channel, is_signed)
VALUES (%s, %s, %s, %s, %s, %s, 'FALSE')
@@ -1222,6 +1249,18 @@ def add_user_api(user_data: AddUserRequest):
user_data.fee,
user_data.payment_channel
))
else:
insert_sql = """
INSERT INTO gsdh_data (new_id, name, phone, fee, payment_channel, is_signed)
VALUES (%s, %s, %s, %s, %s, 'FALSE')
"""
cur.execute(insert_sql, (
new_id,
user_data.name,
user_data.phone,
user_data.fee,
user_data.payment_channel
))
conn.commit()
cur.close()
@@ -1234,6 +1273,49 @@ def add_user_api(user_data: AddUserRequest):
release_db_connection(conn)
return JSONResponse(content={"success": False, "message": f"添加失败: {str(e)}"}, status_code=500)
@app.post("/api/admin/login")
async def admin_login(request: Request):
"""
管理后台登录验证接口。
"""
try:
body = await request.json()
password = body.get('password', '')
secret = os.getenv("ADD_USER_SECRET", "123quant-speed")
if password == secret:
response = JSONResponse(content={"success": True, "message": "登录成功"})
response.set_cookie(
key="admin_auth",
value="true",
httponly=True,
max_age=60*60*24*7,
samesite="lax"
)
return response
else:
return JSONResponse(content={"success": False, "message": "密码错误"}, status_code=401)
except Exception as e:
return JSONResponse(content={"success": False, "message": str(e)}, status_code=500)
@app.get("/api/admin/check-auth")
async def check_admin_auth(request: Request):
"""
检查管理员是否已登录。
"""
auth_cookie = request.cookies.get("admin_auth")
return {"success": auth_cookie == "true", "authenticated": auth_cookie == "true"}
@app.post("/api/admin/logout")
async def admin_logout():
"""
管理员退出登录。
"""
response = JSONResponse(content={"success": True, "message": "已退出登录"})
response.delete_cookie("admin_auth")
return response
@app.get("/admin", response_class=HTMLResponse)
async def admin_page(request: Request):
"""
@@ -1264,7 +1346,7 @@ def get_wall_data():
# 获取最新的签到数据(按时间倒序)
query = """
SELECT gsdh_id, name, company_name, position, business_scope, vision_2026, social_point
SELECT gsdh_id, name, company_name, position, business_scope, vision_2026, social_point, location
FROM checkin_info
ORDER BY created_at DESC
"""
@@ -1377,9 +1459,22 @@ def reset_database():
""")
# Initial Data (Optional - add a test user)
# 先检查 industry_company 列是否存在
cur.execute("""
SELECT column_name FROM information_schema.columns
WHERE table_name = 'gsdh_data' AND column_name = 'industry_company'
""")
has_industry_col = cur.fetchone() is not None
if has_industry_col:
cur.execute("""
INSERT INTO gsdh_data (new_id, name, phone, industry_company, fee, payment_channel, is_signed)
VALUES ('1', '测试用户', '13800000000', '科技', '0', 'test', 'FALSE');
VALUES ('1', '测试用户', '13800000000', '科技', '0', 'test', 'FALSE')
""")
else:
cur.execute("""
INSERT INTO gsdh_data (new_id, name, phone, fee, payment_channel, is_signed)
VALUES ('1', '测试用户', '13800000000', '0', 'test', 'FALSE')
""")
conn.commit()
@@ -1479,6 +1574,7 @@ def init_database():
cur.execute(f"ALTER TABLE {table} ADD COLUMN {col} {dtype}")
print(f"Added column {col} to {table}")
safe_add_column('gsdh_data', 'industry_company', 'VARCHAR(200)')
safe_add_column('gsdh_data', 'fee', 'VARCHAR(50)')
safe_add_column('gsdh_data', 'payment_channel', 'VARCHAR(50)')
safe_add_column('gsdh_data', 'out_trade_no', 'VARCHAR(100)')
@@ -1505,6 +1601,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
# ==========================================
@@ -1661,6 +1918,241 @@ async def success_page(request: Request):
CONFIG = load_config()
return templates.TemplateResponse("success.html", {"request": request, "config": CONFIG})
@app.get("/data-base", response_class=HTMLResponse)
async def data_base_page(request: Request):
"""
渲染数据库管理页面。
"""
return templates.TemplateResponse("data_base.html", {"request": request})
@app.get("/api/db/tables")
def get_db_tables():
"""
获取数据库中所有的表。
"""
try:
conn = get_db_connection()
cur = conn.cursor(cursor_factory=RealDictCursor)
cur.execute("""
SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'public'
AND table_type = 'BASE TABLE'
ORDER BY table_name
""")
rows = cur.fetchall()
tables = [row['table_name'] for row in rows]
cur.close()
release_db_connection(conn)
return {"success": True, "tables": tables}
except Exception as e:
return JSONResponse(content={"success": False, "message": str(e)}, status_code=500)
@app.get("/api/db/table/{table_name}")
def get_table_data(table_name: str, page: int = 1, page_size: int = 50):
"""
获取指定表的数据。
"""
try:
conn = get_db_connection()
cur = conn.cursor(cursor_factory=RealDictCursor)
# 获取主键
cur.execute("""
SELECT a.attname AS column_name
FROM pg_index i
JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey)
WHERE i.indrelid = %s::regclass
AND i.indisprimary
""", (table_name,))
pk_result = cur.fetchone()
primary_key = pk_result['column_name'] if pk_result else ''
# 获取总行数
cur.execute(f'SELECT COUNT(*) as count FROM "{table_name}"')
total = cur.fetchone()['count']
# 分页查询
offset = (page - 1) * page_size
query = f'SELECT * FROM "{table_name}" ORDER BY "{primary_key}" LIMIT %s OFFSET %s'
cur.execute(query, (page_size, offset))
rows = cur.fetchall()
# 转换为普通字典
result_rows = []
for row in rows:
result_rows.append(dict(row))
# 获取列名
columns = list(result_rows[0].keys()) if result_rows else []
cur.close()
release_db_connection(conn)
return {
"success": True,
"rows": result_rows,
"columns": columns,
"total": total,
"primary_key": primary_key,
"page": page,
"page_size": page_size
}
except Exception as e:
return JSONResponse(content={"success": False, "message": str(e)}, status_code=500)
@app.get("/api/db/table/{table_name}/columns")
def get_table_columns(table_name: str):
"""
获取指定表的列信息。
"""
try:
conn = get_db_connection()
cur = conn.cursor(cursor_factory=RealDictCursor)
cur.execute("""
SELECT
column_name,
data_type,
is_nullable,
column_default
FROM information_schema.columns
WHERE table_name = %s
AND table_schema = 'public'
ORDER BY ordinal_position
""", (table_name,))
rows = cur.fetchall()
cur.close()
release_db_connection(conn)
return {"success": True, "columns": rows}
except Exception as e:
return JSONResponse(content={"success": False, "message": str(e)}, status_code=500)
@app.post("/api/db/table/{table_name}/insert")
async def insert_table_data(table_name: str, request: Request):
"""
向指定表插入数据。
"""
try:
body = await request.json()
data = body.get('data', {})
if not data:
return JSONResponse(content={"success": False, "message": "没有数据"}, status_code=400)
conn = get_db_connection()
cur = conn.cursor()
columns = list(data.keys())
values = list(data.values())
placeholders = ','.join(['%s'] * len(columns))
column_names = ','.join([f'"{col}"' for col in columns])
query = f'INSERT INTO "{table_name}" ({column_names}) VALUES ({placeholders})'
cur.execute(query, values)
conn.commit()
cur.close()
release_db_connection(conn)
return {"success": True, "message": "添加成功"}
except Exception as e:
return JSONResponse(content={"success": False, "message": str(e)}, status_code=500)
@app.put("/api/db/table/{table_name}/update")
async def update_table_data(table_name: str, request: Request):
"""
更新指定表的数据。
"""
try:
body = await request.json()
data = body.get('data', {})
record_id = body.get('id')
if not data or not record_id:
return JSONResponse(content={"success": False, "message": "缺少参数"}, status_code=400)
conn = get_db_connection()
cur = conn.cursor(cursor_factory=RealDictCursor)
# 获取主键
cur.execute("""
SELECT a.attname AS column_name
FROM pg_index i
JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey)
WHERE i.indrelid = %s::regclass
AND i.indisprimary
""", (table_name,))
pk_result = cur.fetchone()
primary_key = pk_result['column_name'] if pk_result else None
if not primary_key:
return JSONResponse(content={"success": False, "message": "表没有主键,无法更新"}, status_code=400)
# 构建更新语句
set_clause = ','.join([f'"{col}" = %s' for col in data.keys()])
values = list(data.values())
values.append(record_id)
query = f'UPDATE "{table_name}" SET {set_clause} WHERE "{primary_key}" = %s'
cur.execute(query, values)
conn.commit()
cur.close()
release_db_connection(conn)
return {"success": True, "message": "更新成功"}
except Exception as e:
return JSONResponse(content={"success": False, "message": str(e)}, status_code=500)
@app.delete("/api/db/table/{table_name}/delete")
async def delete_table_data(table_name: str, request: Request):
"""
删除指定表的数据。
"""
try:
body = await request.json()
ids = body.get('ids', [])
if not ids:
return JSONResponse(content={"success": False, "message": "没有要删除的记录"}, status_code=400)
conn = get_db_connection()
cur = conn.cursor(cursor_factory=RealDictCursor)
# 获取主键
cur.execute("""
SELECT a.attname AS column_name
FROM pg_index i
JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey)
WHERE i.indrelid = %s::regclass
AND i.indisprimary
""", (table_name,))
pk_result = cur.fetchone()
primary_key = pk_result['column_name'] if pk_result else None
if not primary_key:
return JSONResponse(content={"success": False, "message": "表没有主键,无法删除"}, status_code=400)
# 删除 ids 中的记录
placeholders = ','.join(['%s'] * len(ids))
query = f'DELETE FROM "{table_name}" WHERE "{primary_key}" IN ({placeholders})'
cur.execute(query, ids)
conn.commit()
cur.close()
release_db_connection(conn)
return {"success": True, "message": f"成功删除 {cur.rowcount} 条记录"}
except Exception as e:
return JSONResponse(content={"success": False, "message": str(e)}, status_code=500)
class TicketPaymentRequest(BaseModel):
name: str
phone: str
@@ -1709,6 +2201,13 @@ async def create_payment(req: TicketPaymentRequest, request: Request):
cur.execute("SELECT new_id, fee FROM gsdh_data WHERE phone = %s", (req.phone,))
existing = cur.fetchone()
# 先检查 industry_company 列是否存在
cur.execute("""
SELECT column_name FROM information_schema.columns
WHERE table_name = 'gsdh_data' AND column_name = 'industry_company'
""")
has_industry_col = cur.fetchone() is not None
if existing:
user_id = existing[0]
current_fee = existing[1]
@@ -1720,17 +2219,30 @@ async def create_payment(req: TicketPaymentRequest, request: Request):
release_db_connection(conn)
return JSONResponse(content={"success": False, "message": "该手机号已完成报名,无需重复支付"}, status_code=400)
if has_industry_col:
cur.execute("""
UPDATE gsdh_data
SET name=%s, industry_company=%s, fee='PENDING', payment_channel='wechat_h5_pending', out_trade_no=%s
WHERE new_id=%s
""", (req.name, req.company_name, out_trade_no, user_id))
else:
cur.execute("""
UPDATE gsdh_data
SET name=%s, fee='PENDING', payment_channel='wechat_h5_pending', out_trade_no=%s
WHERE new_id=%s
""", (req.name, out_trade_no, user_id))
else:
user_id = new_id
if has_industry_col:
cur.execute("""
INSERT INTO gsdh_data (new_id, name, phone, industry_company, fee, payment_channel, is_signed, out_trade_no)
VALUES (%s, %s, %s, %s, 'PENDING', 'wechat_h5_pending', 'FALSE', %s)
""", (user_id, req.name, req.phone, req.company_name, out_trade_no))
else:
cur.execute("""
INSERT INTO gsdh_data (new_id, name, phone, fee, payment_channel, is_signed, out_trade_no)
VALUES (%s, %s, %s, 'PENDING', 'wechat_h5_pending', 'FALSE', %s)
""", (user_id, req.name, req.phone, out_trade_no))
# Also store extra info in checkin_info?
# Requirement: "If registration paid then add to gsdh_data"
@@ -1807,6 +2319,13 @@ async def create_native_payment(req: TicketPaymentRequest, request: Request):
cur.execute("SELECT new_id, fee FROM gsdh_data WHERE phone = %s", (req.phone,))
existing = cur.fetchone()
# 先检查 industry_company 列是否存在
cur.execute("""
SELECT column_name FROM information_schema.columns
WHERE table_name = 'gsdh_data' AND column_name = 'industry_company'
""")
has_industry_col = cur.fetchone() is not None
if existing:
user_id = existing[0]
current_fee = existing[1]
@@ -1817,17 +2336,30 @@ async def create_native_payment(req: TicketPaymentRequest, request: Request):
release_db_connection(conn)
return JSONResponse(content={"success": False, "message": "该手机号已完成报名,无需重复支付"}, status_code=400)
if has_industry_col:
cur.execute("""
UPDATE gsdh_data
SET name=%s, industry_company=%s, fee='PENDING', payment_channel='微信线上支付_pending', out_trade_no=%s
WHERE new_id=%s
""", (req.name, req.company_name, out_trade_no, user_id))
else:
cur.execute("""
UPDATE gsdh_data
SET name=%s, fee='PENDING', payment_channel='微信线上支付_pending', out_trade_no=%s
WHERE new_id=%s
""", (req.name, out_trade_no, user_id))
else:
user_id = new_id
if has_industry_col:
cur.execute("""
INSERT INTO gsdh_data (new_id, name, phone, industry_company, fee, payment_channel, is_signed, out_trade_no)
VALUES (%s, %s, %s, %s, 'PENDING', '微信线上支付_pending', 'FALSE', %s)
""", (user_id, req.name, req.phone, req.company_name, out_trade_no))
else:
cur.execute("""
INSERT INTO gsdh_data (new_id, name, phone, fee, payment_channel, is_signed, out_trade_no)
VALUES (%s, %s, %s, 'PENDING', '微信线上支付_pending', 'FALSE', %s)
""", (user_id, req.name, req.phone, out_trade_no))
conn.commit()
cur.close()

BIN
static/qrcode.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

View File

@@ -253,6 +253,9 @@
</div>
<div class="container">
<div style="margin-bottom: 10px;">
<a href="/admin" style="color: var(--text-muted); text-decoration: none;">← 返回管理后台</a>
</div>
<header>
<img src="{{ config.header_image }}" alt="{{ config.event_title }}" class="header-img" onerror="this.style.display='none'">
<h1>添加新嘉宾</h1>
@@ -291,34 +294,69 @@
<div id="submit-success" class="success-msg hidden"></div>
</div>
<div style="text-align: center; margin-top: 20px;">
<div style="text-align: center; margin-top: 20px; display: flex; justify-content: center; gap: 20px; flex-wrap: wrap;">
<a href="/" style="color: var(--text-muted); text-decoration: none; border-bottom: 1px dashed var(--text-muted);">返回签到首页</a>
<a href="/admin" style="color: var(--text-muted); text-decoration: none; border-bottom: 1px dashed var(--text-muted);">← 返回管理后台</a>
</div>
</div>
<script>
// Use secret from backend
const ACCESS_SECRET = "{{ secret }}";
document.addEventListener('DOMContentLoaded', checkAuthStatus);
// Password Check Logic
function checkSecret() {
const input = document.getElementById('secret-input');
const error = document.getElementById('secret-error');
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');
if (input.value === ACCESS_SECRET) {
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.classList.add('hidden');
hideOverlay();
} else {
error.classList.remove('hidden');
input.value = '';
input.focus();
}
} catch (e) {
error.textContent = '网络错误';
error.classList.remove('hidden');
}
}
// Allow pressing Enter to submit password
document.getElementById('secret-input').addEventListener('keypress', function (e) {
if (e.key === 'Enter') {
checkSecret();

File diff suppressed because it is too large Load Diff

870
templates/data_base.html Normal file
View File

@@ -0,0 +1,870 @@
<!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;
padding: 20px;
}
.container {
width: 100%;
max-width: 1400px;
margin: 0 auto;
position: relative;
z-index: 1;
}
h1 {
font-size: 1.8rem;
margin-bottom: 20px;
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);
}
.nav-bar {
display: flex;
gap: 10px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.nav-bar a {
text-decoration: none;
color: var(--text-muted);
padding: 8px 16px;
border-radius: 6px;
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(0, 242, 255, 0.2);
transition: all 0.3s;
}
.nav-bar a:hover, .nav-bar a.active {
background: rgba(0, 242, 255, 0.1);
border-color: var(--primary-color);
color: var(--primary-color);
}
.card {
background: var(--card-bg);
border-radius: 16px;
padding: 20px;
margin-bottom: 20px;
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);
}
.table-list {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 20px;
}
.table-btn {
padding: 10px 20px;
background: rgba(0, 0, 0, 0.4);
border: 1px solid rgba(0, 242, 255, 0.3);
border-radius: 8px;
color: white;
cursor: pointer;
transition: all 0.3s;
}
.table-btn:hover, .table-btn.active {
background: linear-gradient(90deg, #0051ff 0%, #00f2ff 100%);
border-color: var(--primary-color);
}
.toolbar {
display: flex;
gap: 10px;
margin-bottom: 15px;
flex-wrap: wrap;
align-items: center;
}
.toolbar input[type="text"] {
padding: 8px 12px;
background: rgba(0, 0, 0, 0.4);
border: 1px solid rgba(0, 242, 255, 0.3);
border-radius: 6px;
color: white;
outline: none;
}
.toolbar input[type="text"]:focus {
border-color: var(--primary-color);
}
button {
background: linear-gradient(90deg, #0051ff 0%, #00f2ff 100%);
color: white;
padding: 8px 16px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 0.9rem;
transition: all 0.3s;
box-shadow: 0 2px 10px rgba(0, 242, 255, 0.3);
}
button:hover {
transform: translateY(-1px);
box-shadow: 0 4px 15px rgba(0, 242, 255, 0.5);
}
button.btn-success {
background: linear-gradient(90deg, #28a745 0%, #20c997 100%);
box-shadow: 0 2px 10px rgba(32, 201, 151, 0.35);
}
button.btn-danger {
background: linear-gradient(90deg, #cc0000 0%, #ff4d4d 100%);
box-shadow: 0 2px 10px rgba(255, 0, 0, 0.3);
}
button.btn-warning {
background: linear-gradient(90deg, #ffc107 0%, #fd7e14 100%);
box-shadow: 0 2px 10px rgba(255, 193, 7, 0.35);
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.table-container {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
th, td {
padding: 10px 12px;
text-align: left;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
th {
background: rgba(0, 0, 0, 0.3);
color: var(--primary-color);
font-weight: 600;
white-space: nowrap;
position: sticky;
top: 0;
}
tr:hover {
background: rgba(0, 242, 255, 0.05);
}
td {
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.action-btns {
display: flex;
gap: 5px;
}
.action-btns button {
padding: 4px 8px;
font-size: 0.8rem;
}
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 10px;
margin-top: 15px;
}
.pagination button {
padding: 6px 12px;
}
.pagination span {
color: var(--text-muted);
}
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.7);
z-index: 1000;
justify-content: center;
align-items: center;
}
.modal.show {
display: flex;
}
.modal-content {
background: var(--card-bg);
border-radius: 16px;
padding: 25px;
width: 90%;
max-width: 600px;
max-height: 90vh;
overflow-y: auto;
border: 1px solid rgba(0, 242, 255, 0.3);
backdrop-filter: blur(20px);
}
.modal h2 {
margin-bottom: 20px;
color: var(--primary-color);
}
.form-group {
margin-bottom: 15px;
}
.form-group label {
display: block;
margin-bottom: 5px;
color: var(--text-muted);
font-size: 0.9rem;
}
.form-group input, .form-group textarea, .form-group select {
width: 100%;
padding: 10px;
background: rgba(0, 0, 0, 0.4);
border: 1px solid rgba(0, 242, 255, 0.3);
border-radius: 6px;
color: white;
font-size: 0.95rem;
}
.form-group textarea {
min-height: 80px;
resize: vertical;
}
.form-group input:focus, .form-group textarea:focus {
outline: none;
border-color: var(--primary-color);
}
.modal-btns {
display: flex;
gap: 10px;
justify-content: flex-end;
margin-top: 20px;
}
#message {
position: fixed;
top: 20px;
right: 20px;
padding: 15px 25px;
border-radius: 8px;
color: white;
font-weight: bold;
display: none;
z-index: 1001;
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;
}
.table-info {
display: flex;
gap: 20px;
margin-bottom: 15px;
color: var(--text-muted);
font-size: 0.9rem;
}
.loading {
text-align: center;
padding: 40px;
color: var(--text-muted);
}
.empty-state {
text-align: center;
padding: 60px;
color: var(--text-muted);
}
.checkbox-cell {
width: 40px;
text-align: center;
}
.checkbox-cell input {
width: 18px;
height: 18px;
accent-color: var(--primary-color);
}
</style>
</head>
<body>
<!-- Password Overlay -->
<div id="password-overlay" style="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; backdrop-filter: blur(10px);">
<div class="card" style="text-align: center; width: 90%; max-width: 400px;">
<h2 style="margin-bottom: 20px; color: var(--primary-color);">请输入访问密钥</h2>
<div class="form-group">
<input type="password" id="secret-input" placeholder="输入密钥" style="text-align: center;">
</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="nav-bar">
<a href="/admin">← 返回管理后台</a>
</div>
<div class="card">
<h2 style="margin-bottom: 15px; color: var(--primary-color);">选择数据表</h2>
<div class="table-list" id="tableList">
<div class="loading">加载中...</div>
</div>
</div>
<div class="card" id="dataCard" style="display: none;">
<h2 style="margin-bottom: 15px; color: var(--primary-color);" id="tableTitle">数据表</h2>
<div class="table-info" id="tableInfo"></div>
<div class="toolbar">
<input type="text" id="searchInput" placeholder="搜索..." onkeyup="handleSearch()">
<button onclick="loadData()">🔄 刷新</button>
<button class="btn-success" onclick="showInsertModal()"> 新增</button>
<button class="btn-danger" id="deleteSelectedBtn" onclick="deleteSelected()" disabled>🗑️ 删除选中</button>
</div>
<div class="table-container" id="tableContainer">
<div class="loading">请先选择数据表</div>
</div>
<div class="pagination" id="pagination"></div>
</div>
</div>
<div class="modal" id="editModal">
<div class="modal-content">
<h2 id="modalTitle">编辑数据</h2>
<form id="editForm">
<div id="formFields"></div>
<div class="modal-btns">
<button type="button" onclick="closeModal()">取消</button>
<button type="submit" class="btn-success" id="submitBtn">保存</button>
</div>
</form>
</div>
</div>
<div id="message"></div>
<script>
document.addEventListener('DOMContentLoaded', 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();
}
});
let currentTable = '';
let currentPage = 1;
let pageSize = 50;
let totalRows = 0;
let tableColumns = [];
let tableData = [];
let primaryKey = '';
let editingId = null;
// 加载表列表
async function loadTables() {
try {
const res = await fetch('/api/db/tables');
const data = await res.json();
if (data.success) {
const tableList = document.getElementById('tableList');
tableList.innerHTML = '';
data.tables.forEach(table => {
const btn = document.createElement('button');
btn.className = 'table-btn';
btn.textContent = table;
btn.onclick = () => selectTable(table);
if (currentTable === table) btn.classList.add('active');
tableList.appendChild(btn);
});
}
} catch (e) {
showMessage('加载表列表失败', 'error');
}
}
// 选择表
async function selectTable(table) {
currentTable = table;
currentPage = 1;
document.getElementById('dataCard').style.display = 'block';
document.getElementById('tableTitle').textContent = table + ' - 数据管理';
document.querySelectorAll('.table-btn').forEach(btn => {
btn.classList.remove('active');
if (btn.textContent === table) btn.classList.add('active');
});
await loadData();
}
// 加载数据
async function loadData() {
if (!currentTable) return;
try {
const res = await fetch(`/api/db/table/${currentTable}?page=${currentPage}&page_size=${pageSize}`);
const data = await res.json();
if (data.success) {
tableColumns = data.columns;
tableData = data.rows;
totalRows = data.total;
primaryKey = data.primary_key || '';
renderTable();
renderPagination();
} else {
showMessage(data.message || '加载数据失败', 'error');
}
} catch (e) {
showMessage('网络错误', 'error');
}
}
// 渲染表格
function renderTable() {
const container = document.getElementById('tableContainer');
if (tableData.length === 0) {
container.innerHTML = '<div class="empty-state">暂无数据</div>';
return;
}
let html = '<table><thead><tr>';
// 复选框列
html += '<th class="checkbox-cell"><input type="checkbox" onchange="toggleAllCheckboxes(this)"></th>';
// 数据列
tableColumns.forEach(col => {
html += `<th>${col}</th>`;
});
// 操作列
html += '<th>操作</th></tr></thead><tbody>';
tableData.forEach(row => {
const id = primaryKey ? row[primaryKey] : JSON.stringify(row);
html += `<tr data-id="${id}">`;
html += `<td class="checkbox-cell"><input type="checkbox" class="row-checkbox" data-id="${id}"></td>`;
tableColumns.forEach(col => {
let value = row[col];
if (value === null || value === undefined) value = '';
value = String(value);
if (value.length > 50) {
value = value.substring(0, 50) + '...';
}
html += `<td title="${row[col] || ''}">${escapeHtml(value)}</td>`;
});
html += `<td class="action-btns">
<button class="btn-warning" onclick="showEditModal('${escapeId(id)}')">编辑</button>
<button class="btn-danger" onclick="deleteRow('${escapeId(id)}')">删除</button>
</td></tr>`;
});
html += '</tbody></table>';
container.innerHTML = html;
}
// 转义ID用于HTML属性
function escapeId(id) {
return String(id).replace(/'/g, "\\'").replace(/"/g, '&quot;');
}
// HTML转义
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// 渲染分页
function renderPagination() {
const totalPages = Math.ceil(totalRows / pageSize);
const pagination = document.getElementById('pagination');
if (totalPages <= 1) {
pagination.innerHTML = '';
return;
}
let html = `
<button ${currentPage === 1 ? 'disabled' : ''} onclick="goToPage(1)">首页</button>
<button ${currentPage === 1 ? 'disabled' : ''} onclick="goToPage(${currentPage - 1})">上一页</button>
<span>第 ${currentPage} / ${totalPages} 页 (共 ${totalRows} 条)</span>
<button ${currentPage === totalPages ? 'disabled' : ''} onclick="goToPage(${currentPage + 1})">下一页</button>
<button ${currentPage === totalPages ? 'disabled' : ''} onclick="goToPage(${totalPages})">末页</button>
`;
pagination.innerHTML = html;
}
// 跳转到指定页
function goToPage(page) {
currentPage = page;
loadData();
}
// 搜索
let searchTimeout;
function handleSearch() {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
const keyword = document.getElementById('searchInput').value;
filterData(keyword);
}, 300);
}
// 过滤数据(前端过滤)
function filterData(keyword) {
if (!keyword) {
renderTable();
return;
}
keyword = keyword.toLowerCase();
const filtered = tableData.filter(row => {
return tableColumns.some(col => {
const val = String(row[col] || '').toLowerCase();
return val.includes(keyword);
});
});
// 临时显示过滤结果
const originalData = tableData;
tableData = filtered;
renderTable();
tableData = originalData;
}
// 全选/取消全选
function toggleAllCheckboxes(source) {
const checkboxes = document.querySelectorAll('.row-checkbox');
checkboxes.forEach(cb => cb.checked = source.checked);
updateDeleteButton();
}
// 更新删除按钮状态
function updateDeleteButton() {
const checked = document.querySelectorAll('.row-checkbox:checked');
document.getElementById('deleteSelectedBtn').disabled = checked.length === 0;
}
// 监听复选框变化
document.addEventListener('change', (e) => {
if (e.target.classList.contains('row-checkbox')) {
updateDeleteButton();
}
});
// 显示新增弹窗
async function showInsertModal() {
editingId = null;
document.getElementById('modalTitle').textContent = '新增数据 - ' + currentTable;
await renderFormFields();
document.getElementById('editModal').classList.add('show');
}
// 显示编辑弹窗
async function showEditModal(id) {
editingId = id;
document.getElementById('modalTitle').textContent = '编辑数据 - ' + currentTable;
// 查找当前行数据
const row = tableData.find(r => {
const pk = primaryKey;
return pk ? String(r[pk]) === id : JSON.stringify(r) === id;
});
await renderFormFields(row);
document.getElementById('editModal').classList.add('show');
}
// 渲染表单字段
async function renderFormFields(data = {}) {
const formFields = document.getElementById('formFields');
// 获取表结构
try {
const res = await fetch(`/api/db/table/${currentTable}/columns`);
const result = await res.json();
if (!result.success) {
showMessage('获取表结构失败', 'error');
return;
}
let html = '';
result.columns.forEach(col => {
const isPrimary = col.column_name === primaryKey;
if (isPrimary && !data[primaryKey]) {
return; // 新增时跳过自增主键
}
let value = data[col.column_name] || '';
html += `<div class="form-group">
<label>${col.column_name}${col.is_nullable === 'YES' ? '' : ' *'}</label>
<input type="text"
name="${col.column_name}"
value="${escapeHtml(String(value))}"
${isPrimary ? 'readonly' : ''}
data-type="${col.data_type}">
</div>`;
});
formFields.innerHTML = html;
} catch (e) {
showMessage('网络错误', 'error');
}
}
// 关闭弹窗
function closeModal() {
document.getElementById('editModal').classList.remove('show');
editingId = null;
}
// 提交表单
document.getElementById('editForm').onsubmit = async (e) => {
e.preventDefault();
const formData = {};
const inputs = document.querySelectorAll('#editForm input');
inputs.forEach(input => {
formData[input.name] = input.value;
});
const url = editingId
? `/api/db/table/${currentTable}/update`
: `/api/db/table/${currentTable}/insert`;
const method = editingId ? 'PUT' : 'POST';
try {
const res = await fetch(url, {
method: method,
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
id: editingId,
data: formData
})
});
const result = await res.json();
if (result.success) {
showMessage(editingId ? '更新成功' : '添加成功', 'success');
closeModal();
loadData();
} else {
showMessage(result.message || '操作失败', 'error');
}
} catch (e) {
showMessage('网络错误', 'error');
}
};
// 删除单行
async function deleteRow(id) {
if (!confirm('确定要删除这条记录吗?')) return;
try {
const res = await fetch(`/api/db/table/${currentTable}/delete`, {
method: 'DELETE',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ ids: [id] })
});
const result = await res.json();
if (result.success) {
showMessage('删除成功', 'success');
loadData();
} else {
showMessage(result.message || '删除失败', 'error');
}
} catch (e) {
showMessage('网络错误', 'error');
}
}
// 删除选中
async function deleteSelected() {
const checkboxes = document.querySelectorAll('.row-checkbox:checked');
const ids = Array.from(checkboxes).map(cb => cb.dataset.id);
if (ids.length === 0) return;
if (!confirm(`确定要删除选中的 ${ids.length} 条记录吗?`)) return;
try {
const res = await fetch(`/api/db/table/${currentTable}/delete`, {
method: 'DELETE',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ ids: ids })
});
const result = await res.json();
if (result.success) {
showMessage('删除成功', 'success');
loadData();
} else {
showMessage(result.message || '删除失败', 'error');
}
} catch (e) {
showMessage('网络错误', '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);
}
// 初始化
loadTables();
</script>
</body>
</html>

View File

@@ -273,7 +273,22 @@
</style>
</head>
<body>
<!-- Password Overlay -->
<div id="password-overlay" style="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; backdrop-filter: blur(10px);">
<div class="card" style="text-align: center; width: 90%; max-width: 400px;">
<h2 style="margin-bottom: 20px;">请输入访问密钥</h2>
<div class="input-group">
<input type="password" id="secret-input" placeholder="输入密钥" style="text-align: center;">
</div>
<button onclick="checkSecret()">确认</button>
<p id="secret-error" class="error-msg hidden">密钥错误</p>
</div>
</div>
<div class="container">
<div style="margin-bottom: 10px;">
<a href="/admin" style="color: var(--text-muted); text-decoration: none;">← 返回管理后台</a>
</div>
<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;">
@@ -380,10 +395,75 @@
<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>
<a href="/admin">
<button style="margin-top: 15px; background: transparent; border: 1px solid rgba(255,255,255,0.2);">← 返回管理后台</button>
</a>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', 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.classList.add('hidden');
hideOverlay();
} else {
error.classList.remove('hidden');
input.value = '';
input.focus();
}
} catch (e) {
error.textContent = '网络错误';
error.classList.remove('hidden');
}
}
document.getElementById('secret-input').addEventListener('keypress', function (e) {
if (e.key === 'Enter') {
checkSecret();
}
});
async function searchUser() {
const query = document.getElementById('search-input').value.trim();
const btn = document.getElementById('search-btn');

View File

@@ -463,6 +463,45 @@
100% { box-shadow: 0 0 0 0 rgba(255, 0, 0, 0); }
}
/* 入群二维码样式 */
.qrcode-section {
margin-top: 25px;
padding: 20px;
background: rgba(255, 255, 255, 0.05);
border-radius: 12px;
border: 1px solid rgba(0, 242, 255, 0.3);
text-align: center;
}
.qrcode-title {
color: var(--primary-color);
font-size: 0.9rem;
margin-bottom: 15px;
font-weight: 600;
}
.qrcode-image {
max-width: 180px;
width: 100%;
border-radius: 8px;
border: 1px solid rgba(0, 242, 255, 0.4);
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3);
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
}
.qrcode-image:hover {
transform: scale(1.02);
box-shadow: 0 6px 20px rgba(0, 242, 255, 0.3);
}
.qrcode-hint {
color: var(--text-muted);
font-size: 0.75rem;
margin-top: 10px;
opacity: 0.8;
}
</style>
</head>
<body>
@@ -532,25 +571,8 @@
</div>
</div>
<div class="input-group hidden" id="group-company">
<label id="label-company">单位名称</label>
<input type="text" id="form-company" placeholder="点此输入">
</div>
<div class="input-group hidden" id="group-position">
<label id="label-position">职务</label>
<input type="text" id="form-position" placeholder="点此输入">
</div>
<div class="input-group hidden" id="group-business">
<label id="label-business">公司主要经营 / 业务</label>
<textarea id="form-business" rows="2" placeholder="点此输入"></textarea>
</div>
<div class="input-group hidden" id="group-vision">
<label id="label-vision">2026年业务愿景</label>
<textarea id="form-vision" rows="3" placeholder="点此输入 (为了业务更好对接,最好表述清晰)"></textarea>
</div>
<!-- 动态签到字段容器 - 根据 checkin_field_config 自动生成 -->
<div id="dynamic-checkin-fields"></div>
<input type="hidden" id="form-location" value="">
@@ -576,6 +598,12 @@
</div>
</div>
<div id="qrcode-container-success" class="qrcode-section hidden">
<p class="qrcode-title">📱 扫码加入社群</p>
<img id="qrcode-img-success" class="qrcode-image" src="" alt="入群二维码" oncontextmenu="handleQrcodeLongPress(event)">
<p class="qrcode-hint">长按二维码保存到相册</p>
</div>
<p style="margin-top: 20px;">请入座等待会议开始</p>
<button id="success-btn" onclick="window.location.href='/wall'" style="margin-top: 30px; background: transparent; border: 1px solid rgba(255,255,255,0.2);">前往活动</button>
</div>
@@ -601,6 +629,12 @@
</div>
</div>
<div id="qrcode-container-signed" class="qrcode-section hidden">
<p class="qrcode-title">📱 扫码加入社群</p>
<img id="qrcode-img-signed" class="qrcode-image" src="" alt="入群二维码" oncontextmenu="handleQrcodeLongPress(event)">
<p class="qrcode-hint">长按二维码保存到相册</p>
</div>
<p style="margin-top: 20px;">请入座等待会议开始</p>
<button id="signed-btn" onclick="window.location.href='/wall'" style="margin-top: 30px; background: transparent; border: 1px solid rgba(255,255,255,0.2);">前往活动</button>
</div>
@@ -646,6 +680,25 @@
let isCheckinCodeVerified = false;
/**
* 获取座位单位名称
*/
function getSeatUnitName() {
return CONFIG.seat_unit_name || '桌';
}
/**
* 格式化座位号显示(替换"桌"为单位名称)
* @param {string} seatText 原始座位文字
* @returns {string} 格式化后的座位文字
*/
function formatSeatDisplay(seatText) {
if (!seatText) return '';
const unit = getSeatUnitName();
if (unit === '桌') return seatText;
return seatText.replace(/桌/g, unit);
}
async function refreshConfig() {
try {
const res = await fetch('/api/admin/config');
@@ -660,6 +713,52 @@
}
}
/**
* 动态渲染签到表单字段
* 根据 checkin_field_config 配置生成字段 HTML
*/
function renderDynamicCheckinFields() {
const container = document.getElementById('dynamic-checkin-fields');
if (!container) return;
const fc = CONFIG.checkin_field_config || {};
container.innerHTML = '';
// 定义字段显示顺序
const fieldOrder = ['company_name', 'position', 'business_scope', 'vision_2026'];
// 按顺序添加字段
fieldOrder.forEach(key => {
const field = fc[key];
if (!field) return;
const div = document.createElement('div');
div.className = 'input-group' + (field.show ? '' : ' hidden');
div.id = 'group-' + key;
const isTextarea = (key === 'business_scope' || key === 'vision_2026');
let placeholder = '点此输入';
if (key === 'business_scope') placeholder = '点此输入';
if (key === 'vision_2026') placeholder = '点此输入 (为了业务更好对接,最好表述清晰)';
if (isTextarea) {
const rows = key === 'vision_2026' ? 3 : 2;
div.innerHTML = `
<label id="label-${key}">${field.label || key}</label>
<textarea id="form-${key}" rows="${rows}" placeholder="${placeholder}"></textarea>
`;
} else {
div.innerHTML = `
<label id="label-${key}">${field.label || key}</label>
<input type="text" id="form-${key}" placeholder="${placeholder}">
`;
}
container.appendChild(div);
});
}
function applyConfigUI() {
const smsGroup = document.getElementById('sms-verification-group');
if (smsGroup) {
@@ -670,38 +769,8 @@
}
}
// Apply field config (Show/Hide/Required)
const fc = CONFIG.checkin_field_config || {};
const fields = [
{ key: 'company_name', groupId: 'group-company', inputId: 'form-company', labelId: 'label-company' },
{ key: 'position', groupId: 'group-position', inputId: 'form-position', labelId: 'label-position' },
{ key: 'business_scope', groupId: 'group-business', inputId: 'form-business', labelId: 'label-business' },
{ key: 'vision_2026', groupId: 'group-vision', inputId: 'form-vision', labelId: 'label-vision' }
];
fields.forEach(field => {
const conf = fc[field.key] || { show: false, required: false, label: '' };
const group = document.getElementById(field.groupId);
const input = document.getElementById(field.inputId);
const label = document.getElementById(field.labelId);
if (group && input) {
// Visibility
if (conf.show) {
group.classList.remove('hidden');
} else {
group.classList.add('hidden');
}
// Required
input.required = conf.required === true;
// Label
if (label && conf.label) {
label.textContent = conf.label;
}
}
});
// 动态渲染字段
renderDynamicCheckinFields();
}
function updateCheckinButtonState() {
@@ -844,7 +913,7 @@
document.getElementById('signed-phone').textContent = data.user.phone;
if (CONFIG.enable_seating !== false) {
document.getElementById('signed-seat-display').textContent = data.seat || "自由";
document.getElementById('signed-seat-display').textContent = formatSeatDisplay(data.seat) || ("自由" + getSeatUnitName());
document.getElementById('signed-seat-display').style.display = 'block';
document.getElementById('signed-seat-label').textContent = "您的座位是";
document.getElementById('signed-seat-label').style.display = 'block';
@@ -860,6 +929,9 @@
document.getElementById('signed-seat-label').textContent = "签到成功,请自由入座";
document.getElementById('signed-tablemates-container').classList.add('hidden');
}
// 显示二维码(如果配置开启)
updateQrcodeDisplay();
} else {
// Single user found
await refreshConfig();
@@ -906,11 +978,14 @@
setVal('form-name', user.name);
setVal('form-phone', user.phone);
setVal('form-company', user.industry_company);
// Clear other optional fields to avoid carrying over data if user switches
setVal('form-position', user.position); // Note: user.position might not exist in search result
setVal('form-business', user.business_scope);
setVal('form-vision', user.vision_2026);
// 动态字段填充
const fc = CONFIG.checkin_field_config || {};
Object.keys(fc).forEach(key => {
if (key === 'name' || key === 'phone') return;
// 特殊映射company_name 字段填充 industry_company 的值
const fieldKey = (key === 'company_name') ? 'industry_company' : key;
setVal('form-' + key, user[fieldKey] || '');
});
// Display preview
if (document.getElementById('display-name')) document.getElementById('display-name').textContent = user.name;
@@ -941,18 +1016,22 @@
return el ? el.value : '';
};
// 构建 payload
const payload = {
gsdh_id: getVal('gsdh-id'),
name: getVal('form-name'),
phone: getVal('form-phone'),
verification_code: getVal('form-verification-code'),
company_name: getVal('form-company'),
position: getVal('form-position'),
business_scope: getVal('form-business'),
vision_2026: getVal('form-vision'),
location: getVal('form-location')
};
// 动态收集签到字段数据
const fc = CONFIG.checkin_field_config || {};
Object.keys(fc).forEach(key => {
if (key === 'name' || key === 'phone') return;
payload[key] = getVal('form-' + key);
});
try {
const response = await fetch('/api/checkin', {
method: 'POST',
@@ -968,7 +1047,7 @@
document.getElementById('success-btn').textContent = payload.name + '/点击进入活动大厅';
if (CONFIG.enable_seating !== false) {
// Show assigned seat
document.getElementById('seat-display').textContent = data.seat || "自由";
document.getElementById('seat-display').textContent = formatSeatDisplay(data.seat) || ("自由" + getSeatUnitName());
document.getElementById('seat-display').style.display = 'block';
document.getElementById('success-seat-label').textContent = "您的座位已分配";
document.getElementById('success-seat-label').style.display = 'block';
@@ -985,6 +1064,9 @@
document.getElementById('tablemates-container').classList.add('hidden');
}
// 显示二维码(如果配置开启)
updateQrcodeDisplay();
document.getElementById('step-form').classList.add('hidden');
document.getElementById('step-success').classList.remove('hidden');
} else {
@@ -1061,6 +1143,49 @@
updateCheckinButtonState();
}
/**
* 根据配置控制二维码显示
*/
function updateQrcodeDisplay() {
const enableQrcode = CONFIG.enable_qrcode === true;
const qrcodeImage = CONFIG.qrcode_image;
if (enableQrcode && qrcodeImage) {
// 签到成功页面的二维码
const successContainer = document.getElementById('qrcode-container-success');
const successImg = document.getElementById('qrcode-img-success');
if (successContainer) {
successImg.src = qrcodeImage;
successContainer.classList.remove('hidden');
}
// 已签到页面的二维码
const signedContainer = document.getElementById('qrcode-container-signed');
const signedImg = document.getElementById('qrcode-img-signed');
if (signedContainer) {
signedImg.src = qrcodeImage;
signedContainer.classList.remove('hidden');
}
} else {
document.getElementById('qrcode-container-success')?.classList.add('hidden');
document.getElementById('qrcode-container-signed')?.classList.add('hidden');
}
}
/**
* 处理二维码长按保存(在 PC 端右键保存)
*/
function handleQrcodeLongPress(event) {
event.preventDefault();
const img = event.target;
if (img.src) {
const link = document.createElement('a');
link.href = img.src;
link.download = '入群二维码';
link.click();
}
}
async function sendCheckinSms() {
const phone = document.getElementById('form-phone').value.trim();
const errorDiv = document.getElementById('submit-error');

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, {

View File

@@ -152,6 +152,22 @@
border-radius: 10px;
}
/* 座位标签样式 */
.bubble-seat {
position: absolute;
top: -8px;
right: -8px;
background: linear-gradient(135deg, #ff6b6b, #ff4757);
color: white;
font-size: 0.65rem;
font-weight: bold;
padding: 2px 6px;
border-radius: 10px;
box-shadow: 0 2px 8px rgba(255, 71, 87, 0.4);
white-space: nowrap;
border: 2px solid rgba(255, 255, 255, 0.8);
}
/* Central Spotlight Card */
.spotlight-container {
position: absolute;
@@ -217,6 +233,35 @@
-webkit-text-fill-color: transparent;
}
.card-position {
font-size: 1.2rem;
color: var(--primary-color);
margin-bottom: 15px;
}
.card-vision {
font-size: 1.4rem;
color: #dbe4ff;
font-style: italic;
margin-top: 15px;
}
.card-scope {
font-size: 1.2rem;
color: #dbe4ff;
margin-top: 10px;
}
.card-custom-field {
font-size: 1.2rem;
color: #dbe4ff;
margin-top: 10px;
padding: 8px 15px;
background: rgba(255,255,255,0.05);
border-radius: 8px;
display: inline-block;
}
/* Stats Counter */
.stats-counter {
position: absolute;
@@ -241,6 +286,53 @@
color: #aaa;
}
/* 二维码容器 */
.qrcode-container {
position: fixed;
top: 50%;
right: 40px;
transform: translateY(-50%);
z-index: 25;
animation: fadeInUp 0.6s ease-out;
}
.qrcode-box {
background: rgba(10, 20, 40, 0.9);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 2px solid rgba(0, 242, 255, 0.5);
border-radius: 16px;
padding: 20px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5), 0 0 20px rgba(0, 242, 255, 0.2);
}
.qrcode-title {
font-size: 1rem;
font-weight: bold;
color: #fff;
text-align: center;
margin-bottom: 12px;
text-shadow: 0 0 10px rgba(0, 242, 255, 0.5);
}
.qrcode-image {
width: 150px;
height: 150px;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.2);
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(-50%) translateX(20px);
}
to {
opacity: 1;
transform: translateY(-50%) translateX(0);
}
}
/* Mobile Learn More Button */
.mobile-btn-container {
display: none;
@@ -298,9 +390,6 @@
height: 60px;
font-size: 1.5rem;
}
#card-extra {
font-size: 1rem !important;
}
.stats-counter {
bottom: 10px;
right: 10px;
@@ -309,6 +398,23 @@
.stats-number {
font-size: 1.5rem;
}
.qrcode-container {
position: fixed;
top: auto;
bottom: 80px;
right: 50%;
transform: translateX(50%);
}
.qrcode-box {
padding: 12px;
}
.qrcode-title {
font-size: 0.9rem;
}
.qrcode-image {
width: 100px;
height: 100px;
}
}
</style>
@@ -332,25 +438,24 @@
<div class="spotlight-container">
<div class="spotlight-card" id="card">
<div class="user-info">
{% if show.name != false %}
<div class="user-info" id="card-header">
<div class="user-avatar-large" id="card-avatar">?</div>
<div>
<span id="card-name" style="font-weight: bold; color: white;"></span>
{% if show.position != false %}
<span id="card-position" style="font-size: 0.8em; margin-left: 10px; color: var(--primary-color);"></span>
{% endif %}
</div>
{% endif %}
</div>
{% if show.company_name != false %}
<div class="company-name" id="card-company"></div>
{% endif %}
<div id="card-fields" style="margin-top: 20px;">
<!-- 动态字段将在这里渲染 -->
</div>
</div>
</div>
{% if show.vision_2026 != false or show.business_scope != false %}
<div id="card-extra" style="font-size: 1.4rem; color: #dbe4ff; font-style: italic; margin-top: 20px;"></div>
{% endif %}
<!-- 扫码签到二维码 -->
<div class="qrcode-container" id="qrcode-container" style="display: none;">
<div class="qrcode-box">
<div class="qrcode-title">扫码签到</div>
<img src="/static/qrcode.png" alt="签到二维码" class="qrcode-image" id="wall-qrcode">
</div>
</div>
@@ -378,8 +483,10 @@
const isMobile = () => window.innerWidth <= 768;
const SHOW_VISION = {{ 'true' if show.vision_2026 != false else 'false' }};
const SHOW_SCOPE = {{ 'true' if show.business_scope != false else 'false' }};
// 大屏显示字段配置(从后端获取)
const WALL_SHOW_FIELDS = {{ config.wall_config.show_fields | tojson if config.wall_config and config.wall_config.show_fields else '{}' }};
const CHECKIN_FIELD_CONFIG = {{ config.checkin_field_config | tojson if config.checkin_field_config else '{}' }};
const WALL_CONFIG = {{ config.wall_config | tojson if config.wall_config else '{}' }};
// --- State ---
let allUsers = []; // All fetched users
@@ -400,6 +507,18 @@
if (isGridMode) arrangeGrid();
});
// 控制二维码显示/隐藏
const qrcodeContainer = document.getElementById('qrcode-container');
if (qrcodeContainer) {
// 如果 show_qrcode 为 true 则显示,默认为 false
if (WALL_CONFIG.show_qrcode === true) {
qrcodeContainer.style.display = 'block';
}
}
// 随机图标列表
const RANDOM_ICONS = ['🎯', '⭐', '🌟', '💫', '✨', '🎊', '🎉', '🎈', '🚀', '💎', '🏆', '🎖️', '🌈', '🦄', '🎪', '🎨', '🎭', '🎪', '🎯', '🎲'];
// --- Class: UserBubble ---
class UserBubble {
constructor(user) {
@@ -408,17 +527,33 @@
this.element = document.createElement('div');
this.element.className = 'user-bubble';
// Content
const name = user.name || '嘉宾';
// Dynamic font size based on name length
const showName = WALL_SHOW_FIELDS.name !== false;
let bubbleContent;
let fontSize = '0.9rem';
if (showName) {
const name = user.name || '嘉宾';
if (name.length > 3) fontSize = '0.7rem';
if (name.length > 4) fontSize = '0.6rem';
bubbleContent = name;
} else {
const randomIndex = Math.floor(Math.random() * RANDOM_ICONS.length);
bubbleContent = RANDOM_ICONS[randomIndex];
fontSize = '1.5rem';
}
this.element.innerHTML = `
<div class="bubble-avatar" style="font-size: ${fontSize}">${name}</div>
<div class="bubble-avatar" style="font-size: ${fontSize}">${bubbleContent}</div>
`;
// 显示座位信息(如果启用且用户有座位号)
if (WALL_CONFIG.show_seating === true && user.location) {
const seatElement = document.createElement('div');
seatElement.className = 'bubble-seat';
seatElement.textContent = user.location;
this.element.appendChild(seatElement);
}
// Interaction: Click to show in spotlight
this.element.style.cursor = 'pointer';
this.element.addEventListener('click', (e) => {
@@ -665,29 +800,58 @@
const card = document.getElementById('card');
card.classList.remove('active');
// Wait for fade out
setTimeout(() => {
if(document.getElementById('card-name')) document.getElementById('card-name').textContent = user.name;
if(document.getElementById('card-position')) document.getElementById('card-position').textContent = user.position || '';
if(document.getElementById('card-company')) document.getElementById('card-company').textContent = user.company_name || '嘉宾';
if(document.getElementById('card-avatar')) document.getElementById('card-avatar').textContent = user.name ? user.name.charAt(0) : '?';
const showName = WALL_SHOW_FIELDS.name !== false;
const cardHeader = document.getElementById('card-header');
const extraDiv = document.getElementById('card-extra');
if (extraDiv) {
if (SHOW_VISION && user.vision_2026) {
extraDiv.textContent = "🎯 " + user.vision_2026;
extraDiv.style.display = 'block';
} else if (SHOW_SCOPE && user.business_scope) {
extraDiv.textContent = user.business_scope;
extraDiv.style.display = 'block';
if (showName) {
cardHeader.style.display = 'flex';
if(document.getElementById('card-name')) document.getElementById('card-name').textContent = user.name || '';
if(document.getElementById('card-avatar')) document.getElementById('card-avatar').textContent = user.name ? user.name.charAt(0) : '?';
} else {
extraDiv.style.display = 'none';
}
cardHeader.style.display = 'none';
}
renderCardFields(user);
card.classList.add('active');
}, 800);
}
/**
* 根据配置动态渲染卡片字段
*/
function renderCardFields(user) {
const container = document.getElementById('card-fields');
container.innerHTML = '';
let html = '';
Object.keys(WALL_SHOW_FIELDS).forEach(key => {
if (!WALL_SHOW_FIELDS[key]) return;
const fieldConfig = CHECKIN_FIELD_CONFIG[key];
if (!fieldConfig) return;
const fieldValue = user[key] || '';
if (!fieldValue) return;
if (key === 'company_name') {
html += `<div class="company-name">${fieldValue}</div>`;
} else if (key === 'vision_2026') {
html += `<div class="card-vision">🎯 ${fieldValue}</div>`;
} else if (key === 'business_scope') {
html += `<div class="card-scope">${fieldValue}</div>`;
} else if (key === 'position') {
html += `<div class="card-position">${fieldValue}</div>`;
} else {
html += `<div class="card-custom-field">${fieldValue}</div>`;
}
});
container.innerHTML = html;
}
function startSpotlight() {
if (isMobile()) return;
if (spotlightInterval) clearInterval(spotlightInterval);