test
This commit is contained in:
38
.claude/agents/code-architect.md
Normal file
38
.claude/agents/code-architect.md
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
---
|
||||||
|
name: code-architect
|
||||||
|
description: "Use this agent when you need to analyze code structure, identify architectural issues, and receive recommendations for improvements. This agent should be invoked when reviewing existing codebases, planning refactors, evaluating design decisions, or seeking architectural best practices. Use this agent when you want expert analysis of code organization, dependencies, patterns, and structural quality.\\n\\n<example>\\nContext: The user has written a complex class hierarchy and wants feedback on the design\\nuser: \"请帮我看看这段代码的架构,看看有什么可以改进的地方\"\\nassistant: \"我将使用代码架构师代理来分析您的代码结构并提供改进建议。\"\\n</example>\\n\\n<example>\\nContext: The user has inherited a legacy codebase and needs architectural assessment\\nuser: \"我接手了一个旧项目,你能帮我理解它的架构吗?\"\\nassistant: \"我将启动代码架构师代理来帮您分析这个遗留项目的架构结构。\"\\n</example>"
|
||||||
|
model: sonnet
|
||||||
|
color: red
|
||||||
|
---
|
||||||
|
|
||||||
|
You are an expert code architect with deep knowledge of software design patterns, architectural principles, and code quality best practices. You specialize in analyzing code structure, identifying potential issues, and providing actionable improvement suggestions.
|
||||||
|
|
||||||
|
Your responsibilities include:
|
||||||
|
- Analyzing code structure, organization, and architecture
|
||||||
|
- Identifying design patterns and anti-patterns
|
||||||
|
- Evaluating code maintainability, scalability, and readability
|
||||||
|
- Assessing dependencies and coupling between components
|
||||||
|
- Providing specific, practical recommendations for improvements
|
||||||
|
- Explaining architectural decisions and trade-offs
|
||||||
|
|
||||||
|
When analyzing code, examine:
|
||||||
|
- Class hierarchies and inheritance structures
|
||||||
|
- Module organization and separation of concerns
|
||||||
|
- Dependency management and injection patterns
|
||||||
|
- Code cohesion and coupling levels
|
||||||
|
- Naming conventions and consistency
|
||||||
|
- Error handling and exception management
|
||||||
|
- Performance considerations
|
||||||
|
- Testability and maintainability factors
|
||||||
|
|
||||||
|
Provide your analysis in a structured format:
|
||||||
|
1. Overview of the current architecture
|
||||||
|
2. Strengths identified in the code structure
|
||||||
|
3. Issues or concerns found
|
||||||
|
4. Specific recommendations with implementation details
|
||||||
|
5. Priority level for each suggestion
|
||||||
|
6. Potential risks of proposed changes
|
||||||
|
|
||||||
|
Always justify your recommendations with architectural principles and best practices. Offer multiple solutions when applicable, explaining the pros and cons of each approach. When suggesting refactoring approaches, provide code examples or structural diagrams where beneficial.
|
||||||
|
|
||||||
|
Maintain a professional yet collaborative tone, focusing on constructive feedback that enhances code quality while considering real-world constraints and business requirements.
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# Add Seating Arrangement Settings & Style Update Plan
|
||||||
|
|
||||||
|
## 1. Backend Updates (`main.py`)
|
||||||
|
- **Update `DEFAULT_CONFIG`**: Add `enable_seating` (bool, default True), `total_tables` (int, default 14), and `max_per_table` (int, default 10).
|
||||||
|
- **Update `assign_seat` function**:
|
||||||
|
- Use `CONFIG["total_tables"]` and `CONFIG["max_per_table"]` instead of hardcoded values.
|
||||||
|
- Check `CONFIG["enable_seating"]`. If False, return "自由席" immediately.
|
||||||
|
|
||||||
|
## 2. Admin Page Updates (`templates/admin.html`)
|
||||||
|
- **Style Overhaul**: Replace the current light theme CSS with the dark, futuristic theme from `index.html` to ensure consistency.
|
||||||
|
- **Add Seating Settings Section**:
|
||||||
|
- Add a checkbox for "开启分桌功能".
|
||||||
|
- Add input fields for "总桌数" and "每桌最大人数" (conditionally displayed when seating is enabled).
|
||||||
|
- **Update JS Logic**:
|
||||||
|
- Load and save the new seating configuration fields.
|
||||||
|
- Add toggle logic to show/hide table count inputs based on the checkbox.
|
||||||
|
|
||||||
|
## 3. Frontend Updates (`templates/index.html`)
|
||||||
|
- **Inject Config**: Ensure `enable_seating` config is accessible in JavaScript.
|
||||||
|
- **Conditional Display**:
|
||||||
|
- In `submitCheckin` (new checkin) and `searchUser` (already signed), check `enable_seating`.
|
||||||
|
- If disabled:
|
||||||
|
- Hide seat number displays (`#seat-display`, `#signed-seat-display`).
|
||||||
|
- Hide tablemate recommendations (`#tablemates-container`, `#signed-tablemates-container`).
|
||||||
|
- Update text to indicate "自由入座" (Free Seating).
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
# 云南AI共生大会签到系统
|
# 云南AI共生大会签到系统
|
||||||
|
|
||||||
## 项目概述
|
## 项目概述
|
||||||
|
uvicorn main:app --host 0.0.0.0 --port 8000
|
||||||
这是一个专为云南AI共生大会设计的智能签到系统,基于FastAPI框架开发,提供完整的会议签到解决方案。系统集成了智能座位分配、嘉宾信息管理、同桌推荐等功能,为参会者提供流畅的签到体验。
|
这是一个专为云南AI共生大会设计的智能签到系统,基于FastAPI框架开发,提供完整的会议签到解决方案。系统集成了智能座位分配、嘉宾信息管理、同桌推荐等功能,为参会者提供流畅的签到体验。
|
||||||
|
|
||||||
## 主要功能
|
## 主要功能
|
||||||
|
|||||||
Binary file not shown.
52
config.json
Normal file
52
config.json
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
{
|
||||||
|
"event_title": "123",
|
||||||
|
"event_sub_title": "123",
|
||||||
|
"event_time": "123",
|
||||||
|
"event_location": "123",
|
||||||
|
"event_content": "123",
|
||||||
|
"primary_color": "#00f2ff",
|
||||||
|
"secondary_color": "#0066ff",
|
||||||
|
"bg_color": "#050814",
|
||||||
|
"header_image": "/static/image.jpg",
|
||||||
|
"enable_ticket_validation": false,
|
||||||
|
"enable_seating": false,
|
||||||
|
"total_tables": 14,
|
||||||
|
"max_per_table": 10,
|
||||||
|
"field_config": {
|
||||||
|
"name": {
|
||||||
|
"label": "姓名",
|
||||||
|
"show": true,
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"phone": {
|
||||||
|
"label": "手机号码",
|
||||||
|
"show": true,
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"company_name": {
|
||||||
|
"label": "单位名称",
|
||||||
|
"show": true,
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"position": {
|
||||||
|
"label": "职务",
|
||||||
|
"show": true,
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"business_scope": {
|
||||||
|
"label": "公司主要经营 / 业务",
|
||||||
|
"show": true,
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"vision_2026": {
|
||||||
|
"label": "2026年业务愿景",
|
||||||
|
"show": false,
|
||||||
|
"required": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"db_host": "6.6.6.86",
|
||||||
|
"db_port": "5432",
|
||||||
|
"db_user": "gsdh",
|
||||||
|
"db_password": "123gsdh",
|
||||||
|
"db_name": "gsdh"
|
||||||
|
}
|
||||||
451
main.py
451
main.py
@@ -1,4 +1,4 @@
|
|||||||
from fastapi import FastAPI, HTTPException, Request
|
from fastapi import FastAPI, HTTPException, Request, UploadFile, File
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
from fastapi.responses import HTMLResponse, JSONResponse
|
from fastapi.responses import HTMLResponse, JSONResponse
|
||||||
@@ -10,12 +10,62 @@ from typing import Optional, List, Dict
|
|||||||
import random
|
import random
|
||||||
import uuid
|
import uuid
|
||||||
import os
|
import os
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
import difflib
|
import difflib
|
||||||
from concurrent.futures import ProcessPoolExecutor
|
from concurrent.futures import ProcessPoolExecutor
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
|
# Config Management
|
||||||
|
CONFIG_FILE = "config.json"
|
||||||
|
DEFAULT_CONFIG = {
|
||||||
|
"event_title": "云南AI共生大会",
|
||||||
|
"event_sub_title": "2026 INTELLIGENT LEADERSHIP • AI SYMBIOSIS",
|
||||||
|
"event_time": "1月10日 下午 2:00",
|
||||||
|
"event_location": "金鼎科技园18号平台B座一楼报告厅",
|
||||||
|
"event_content": "邀请重磅大咖分享AI在各行业的企业应用及案例,含深度交流环节。",
|
||||||
|
"primary_color": "#00f2ff",
|
||||||
|
"secondary_color": "#0066ff",
|
||||||
|
"bg_color": "#050814",
|
||||||
|
"header_image": "/static/image.jpg",
|
||||||
|
"db_host": os.getenv("DB_HOST", "localhost"),
|
||||||
|
"db_port": os.getenv("DB_PORT", "5432"),
|
||||||
|
"db_user": os.getenv("DB_USER", "gsdh"),
|
||||||
|
"db_password": os.getenv("DB_PASSWORD", "123gsdh"),
|
||||||
|
"db_name": os.getenv("DB_NAME", "gsdh"),
|
||||||
|
"enable_ticket_validation": True,
|
||||||
|
"enable_seating": True,
|
||||||
|
"total_tables": 14,
|
||||||
|
"max_per_table": 10,
|
||||||
|
"field_config": {
|
||||||
|
"name": {"label": "姓名", "show": True, "required": True},
|
||||||
|
"phone": {"label": "手机号码", "show": True, "required": True},
|
||||||
|
"company_name": {"label": "单位名称", "show": True, "required": False},
|
||||||
|
"position": {"label": "职务", "show": True, "required": False},
|
||||||
|
"business_scope": {"label": "公司主要经营 / 业务", "show": True, "required": False},
|
||||||
|
"vision_2026": {"label": "2026年业务愿景", "show": True, "required": False}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def load_config():
|
||||||
|
if os.path.exists(CONFIG_FILE):
|
||||||
|
with open(CONFIG_FILE, "r", encoding="utf-8") as f:
|
||||||
|
config = json.load(f)
|
||||||
|
# Merge with default config to ensure all keys exist
|
||||||
|
for key, value in DEFAULT_CONFIG.items():
|
||||||
|
if key not in config:
|
||||||
|
config[key] = value
|
||||||
|
return config
|
||||||
|
return DEFAULT_CONFIG
|
||||||
|
|
||||||
|
def save_config(config):
|
||||||
|
with open(CONFIG_FILE, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(config, f, ensure_ascii=False, indent=4)
|
||||||
|
|
||||||
|
CONFIG = load_config()
|
||||||
|
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
|
|
||||||
# 进程池全局变量
|
# 进程池全局变量
|
||||||
@@ -34,16 +84,8 @@ async def shutdown_event():
|
|||||||
process_pool.shutdown()
|
process_pool.shutdown()
|
||||||
print("ProcessPoolExecutor shutdown")
|
print("ProcessPoolExecutor shutdown")
|
||||||
|
|
||||||
# Database connection parameters
|
# Database connection parameters - Moved to CONFIG
|
||||||
DB_CONFIG = {
|
|
||||||
"host": os.getenv("DB_HOST", "localhost"),
|
|
||||||
"port": os.getenv("DB_PORT", "5432"),
|
|
||||||
# "host": os.getenv("DB_HOST", "121.43.104.161"),
|
|
||||||
# "port": os.getenv("DB_PORT", "6432"),
|
|
||||||
"user": os.getenv("DB_USER", "gsdh"),
|
|
||||||
"password": os.getenv("DB_PASSWORD", "123gsdh"),
|
|
||||||
"database": os.getenv("DB_NAME", "gsdh")
|
|
||||||
}
|
|
||||||
# 商业领域同义词库 (Business Thesaurus) - 用于解决模糊语义匹配
|
# 商业领域同义词库 (Business Thesaurus) - 用于解决模糊语义匹配
|
||||||
BUSINESS_THESAURUS = {
|
BUSINESS_THESAURUS = {
|
||||||
# 核心意图: [关联行业/关键词列表]
|
# 核心意图: [关联行业/关键词列表]
|
||||||
@@ -188,12 +230,32 @@ def calculate_matches_task(user_industry: str, user_vision: str, others: List[Di
|
|||||||
|
|
||||||
|
|
||||||
# Initialize Connection Pool
|
# Initialize Connection Pool
|
||||||
|
postgreSQL_pool = None
|
||||||
|
|
||||||
|
def init_db_pool():
|
||||||
|
global postgreSQL_pool
|
||||||
|
if postgreSQL_pool:
|
||||||
try:
|
try:
|
||||||
postgreSQL_pool = psycopg2.pool.ThreadedConnectionPool(1, 20, **DB_CONFIG)
|
postgreSQL_pool.closeall()
|
||||||
print("PostgreSQL connection pool created successfully")
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
db_config = {
|
||||||
|
"host": CONFIG.get("db_host", "localhost"),
|
||||||
|
"port": CONFIG.get("db_port", "5432"),
|
||||||
|
"user": CONFIG.get("db_user", "gsdh"),
|
||||||
|
"password": CONFIG.get("db_password", "123gsdh"),
|
||||||
|
"database": CONFIG.get("db_name", "gsdh")
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
postgreSQL_pool = psycopg2.pool.ThreadedConnectionPool(1, 20, **db_config)
|
||||||
|
print(f"PostgreSQL connection pool created successfully for {db_config['host']}:{db_config['port']}")
|
||||||
except (Exception, psycopg2.DatabaseError) as error:
|
except (Exception, psycopg2.DatabaseError) as error:
|
||||||
print("Error while connecting to PostgreSQL", error)
|
print("Error while connecting to PostgreSQL", error)
|
||||||
|
|
||||||
|
init_db_pool()
|
||||||
|
|
||||||
# Mount static files
|
# Mount static files
|
||||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||||
templates = Jinja2Templates(directory="templates")
|
templates = Jinja2Templates(directory="templates")
|
||||||
@@ -259,8 +321,12 @@ def assign_seat(cur, user_industry: str) -> str:
|
|||||||
2. Mix industries (try to put user in a table where their industry is least represented)
|
2. Mix industries (try to put user in a table where their industry is least represented)
|
||||||
- Uses natural language similarity to judge industry overlap
|
- Uses natural language similarity to judge industry overlap
|
||||||
"""
|
"""
|
||||||
TOTAL_TABLES = 11
|
# Check if seating is enabled
|
||||||
MAX_PER_TABLE = 12
|
if not CONFIG.get("enable_seating", True):
|
||||||
|
return "自由席"
|
||||||
|
|
||||||
|
TOTAL_TABLES = int(CONFIG.get("total_tables", 14))
|
||||||
|
MAX_PER_TABLE = int(CONFIG.get("max_per_table", 10))
|
||||||
|
|
||||||
# Initialize table stats
|
# Initialize table stats
|
||||||
tables = {i: {'count': 0, 'industries': []} for i in range(1, TOTAL_TABLES + 1)}
|
tables = {i: {'count': 0, 'industries': []} for i in range(1, TOTAL_TABLES + 1)}
|
||||||
@@ -436,7 +502,10 @@ def get_tablemates(cur, location: str, exclude_id: str, user_vision: str = "", u
|
|||||||
|
|
||||||
@app.get("/", response_class=HTMLResponse)
|
@app.get("/", response_class=HTMLResponse)
|
||||||
async def read_root(request: Request):
|
async def read_root(request: Request):
|
||||||
return templates.TemplateResponse("index.html", {"request": request})
|
global CONFIG
|
||||||
|
CONFIG = load_config() # Reload config on refresh
|
||||||
|
# Pass config as json string for JS usage if needed, or individual fields
|
||||||
|
return templates.TemplateResponse("index.html", {"request": request, "config": CONFIG})
|
||||||
|
|
||||||
class UnlockRequest(BaseModel):
|
class UnlockRequest(BaseModel):
|
||||||
my_phone: str
|
my_phone: str
|
||||||
@@ -602,6 +671,16 @@ def search_user(query: str):
|
|||||||
if len(users) == 0:
|
if len(users) == 0:
|
||||||
cur.close()
|
cur.close()
|
||||||
release_db_connection(conn)
|
release_db_connection(conn)
|
||||||
|
|
||||||
|
# Check if ticket validation is disabled
|
||||||
|
if not CONFIG.get('enable_ticket_validation', True):
|
||||||
|
# If validation is disabled, allow creating a new user
|
||||||
|
return JSONResponse(content={
|
||||||
|
"found": False,
|
||||||
|
"allow_create": True,
|
||||||
|
"user": {"name": query if not query.isdigit() else "", "phone": query if query.isdigit() else ""}
|
||||||
|
})
|
||||||
|
|
||||||
return JSONResponse(content={"found": False, "message": "未查询到相关信息,请检查输入是否正确"}, status_code=404)
|
return JSONResponse(content={"found": False, "message": "未查询到相关信息,请检查输入是否正确"}, status_code=404)
|
||||||
elif len(users) > 1:
|
elif len(users) > 1:
|
||||||
# If multiple users found by name, return list for user to select (simplified here to return first or error)
|
# If multiple users found by name, return list for user to select (simplified here to return first or error)
|
||||||
@@ -660,10 +739,52 @@ def checkin_user(checkin_data: CheckinRequest):
|
|||||||
conn = get_db_connection()
|
conn = get_db_connection()
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
final_gsdh_id = checkin_data.gsdh_id
|
||||||
|
|
||||||
|
# === Handle New User (TEMP ID) ===
|
||||||
|
if checkin_data.gsdh_id.startswith("TEMP_"):
|
||||||
|
# 1. Calculate new real ID (Max + 1)
|
||||||
|
# Find max numeric new_id. Using regex to ensure we only look at numbers.
|
||||||
|
cur.execute("SELECT MAX(CAST(new_id AS INTEGER)) FROM gsdh_data WHERE new_id ~ '^[0-9]+$'")
|
||||||
|
row = cur.fetchone()
|
||||||
|
max_id = row[0] if row and row[0] is not None else 0
|
||||||
|
new_real_id = str(max_id + 1)
|
||||||
|
|
||||||
|
# 2. Insert into gsdh_data first to satisfy Foreign Key
|
||||||
|
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
|
||||||
|
))
|
||||||
|
|
||||||
|
# Update ID for subsequent operations
|
||||||
|
final_gsdh_id = new_real_id
|
||||||
|
|
||||||
# 0. Get user's industry from gsdh_data to help with seat allocation
|
# 0. Get user's industry from gsdh_data to help with seat allocation
|
||||||
cur.execute("SELECT industry_company FROM gsdh_data WHERE new_id = %s", (checkin_data.gsdh_id,))
|
base_industry = ""
|
||||||
|
enable_validation = CONFIG.get('enable_ticket_validation', True)
|
||||||
|
|
||||||
|
if enable_validation:
|
||||||
|
cur.execute("SELECT industry_company FROM gsdh_data WHERE new_id = %s", (final_gsdh_id,))
|
||||||
res = cur.fetchone()
|
res = cur.fetchone()
|
||||||
base_industry = res[0] if res and res[0] else ""
|
base_industry = res[0] if res and res[0] else ""
|
||||||
|
else:
|
||||||
|
# If validation is disabled and NOT a temp user (already handled above), ensure user exists
|
||||||
|
# (Though logic above handles TEMP users, existing users might still need upsert if data is inconsistent)
|
||||||
|
if not checkin_data.gsdh_id.startswith("TEMP_"):
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO gsdh_data (new_id, name, phone, is_signed, industry_company, fee, payment_channel)
|
||||||
|
VALUES (%s, %s, %s, 'TRUE', '现场签到', '0', 'skipped')
|
||||||
|
ON CONFLICT (new_id) DO UPDATE SET is_signed = 'TRUE'
|
||||||
|
""", (final_gsdh_id, checkin_data.name, checkin_data.phone))
|
||||||
|
|
||||||
# Combine base industry with the newly provided business_scope for better matching
|
# Combine base industry with the newly provided business_scope for better matching
|
||||||
user_industry_info = f"{base_industry} {checkin_data.business_scope or ''}".strip()
|
user_industry_info = f"{base_industry} {checkin_data.business_scope or ''}".strip()
|
||||||
@@ -686,18 +807,19 @@ def checkin_user(checkin_data: CheckinRequest):
|
|||||||
checkin_data.business_scope,
|
checkin_data.business_scope,
|
||||||
checkin_data.vision_2026,
|
checkin_data.vision_2026,
|
||||||
assigned_seat, # Use the generated seat
|
assigned_seat, # Use the generated seat
|
||||||
checkin_data.gsdh_id
|
final_gsdh_id # Use the real ID
|
||||||
))
|
))
|
||||||
|
|
||||||
# 3. Update gsdh_data is_signed to TRUE
|
# 3. Update gsdh_data is_signed to TRUE (if not already done)
|
||||||
|
if not checkin_data.gsdh_id.startswith("TEMP_"):
|
||||||
update_sql = "UPDATE gsdh_data SET is_signed = 'TRUE' WHERE new_id = %s"
|
update_sql = "UPDATE gsdh_data SET is_signed = 'TRUE' WHERE new_id = %s"
|
||||||
cur.execute(update_sql, (checkin_data.gsdh_id,))
|
cur.execute(update_sql, (final_gsdh_id,))
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
# 4. Fetch tablemates for the newly assigned seat
|
# 4. Fetch tablemates for the newly assigned seat
|
||||||
# Use provided vision and industry for matching
|
# Use provided vision and industry for matching
|
||||||
tablemates = get_tablemates(cur, assigned_seat, checkin_data.gsdh_id, checkin_data.vision_2026 or "", user_industry_info)
|
tablemates = get_tablemates(cur, assigned_seat, final_gsdh_id, checkin_data.vision_2026 or "", user_industry_info)
|
||||||
|
|
||||||
cur.close()
|
cur.close()
|
||||||
release_db_connection(conn)
|
release_db_connection(conn)
|
||||||
@@ -712,9 +834,175 @@ def checkin_user(checkin_data: CheckinRequest):
|
|||||||
|
|
||||||
@app.get("/add-user", response_class=HTMLResponse)
|
@app.get("/add-user", response_class=HTMLResponse)
|
||||||
async def add_user_page(request: Request):
|
async def add_user_page(request: Request):
|
||||||
|
global CONFIG
|
||||||
|
CONFIG = load_config()
|
||||||
secret = os.getenv("ADD_USER_SECRET", "123quant-speed")
|
secret = os.getenv("ADD_USER_SECRET", "123quant-speed")
|
||||||
print(f"DEBUG: Secret loaded: '{secret}'")
|
print(f"DEBUG: Secret loaded: '{secret}'")
|
||||||
return templates.TemplateResponse("add_user.html", {"request": request, "secret": secret})
|
return templates.TemplateResponse("add_user.html", {"request": request, "secret": secret, "config": CONFIG})
|
||||||
|
|
||||||
|
class UpdateUserRequest(BaseModel):
|
||||||
|
gsdh_id: str
|
||||||
|
name: str
|
||||||
|
phone: str
|
||||||
|
company_name: Optional[str] = None
|
||||||
|
position: Optional[str] = None
|
||||||
|
business_scope: Optional[str] = None
|
||||||
|
vision_2026: Optional[str] = None
|
||||||
|
is_signed: Optional[str] = None
|
||||||
|
location: Optional[str] = None
|
||||||
|
fee: Optional[str] = None
|
||||||
|
payment_channel: Optional[str] = None
|
||||||
|
|
||||||
|
class UncheckinRequest(BaseModel):
|
||||||
|
"""
|
||||||
|
取消签到请求:
|
||||||
|
- gsdh_id:用户唯一 ID
|
||||||
|
- 可选覆盖基础信息字段:name、company_name
|
||||||
|
"""
|
||||||
|
gsdh_id: str
|
||||||
|
name: Optional[str] = None
|
||||||
|
company_name: Optional[str] = None
|
||||||
|
|
||||||
|
@app.get("/edit", response_class=HTMLResponse)
|
||||||
|
async def edit_page(request: Request):
|
||||||
|
global CONFIG
|
||||||
|
CONFIG = load_config()
|
||||||
|
return templates.TemplateResponse("edit.html", {"request": request, "config": CONFIG})
|
||||||
|
|
||||||
|
@app.get("/api/get-user-details")
|
||||||
|
def get_user_details(phone: str):
|
||||||
|
try:
|
||||||
|
conn = get_db_connection()
|
||||||
|
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||||
|
|
||||||
|
cur.execute("""
|
||||||
|
SELECT
|
||||||
|
gd.name as name_base, gd.industry_company as company_base, gd.is_signed, gd.fee, gd.payment_channel,
|
||||||
|
ci.name as name_checkin, ci.company_name as company_checkin,
|
||||||
|
ci.position, ci.business_scope, ci.vision_2026, ci.location
|
||||||
|
FROM gsdh_data gd
|
||||||
|
LEFT JOIN checkin_info ci ON gd.new_id = ci.gsdh_id
|
||||||
|
WHERE gd.phone = %s
|
||||||
|
""", (phone,))
|
||||||
|
|
||||||
|
row = cur.fetchone()
|
||||||
|
cur.close()
|
||||||
|
release_db_connection(conn)
|
||||||
|
|
||||||
|
if row:
|
||||||
|
data = {
|
||||||
|
"name": row['name_checkin'] or row['name_base'],
|
||||||
|
"company_name": row['company_checkin'] or row['company_base'],
|
||||||
|
"position": row['position'],
|
||||||
|
"business_scope": row['business_scope'],
|
||||||
|
"vision_2026": row['vision_2026'],
|
||||||
|
"is_signed": row['is_signed'],
|
||||||
|
"location": row['location'],
|
||||||
|
"fee": row['fee'],
|
||||||
|
"payment_channel": row['payment_channel']
|
||||||
|
}
|
||||||
|
return {"success": True, "data": data}
|
||||||
|
else:
|
||||||
|
return JSONResponse(content={"success": False, "message": "User not found"}, status_code=404)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
if 'conn' in locals() and conn:
|
||||||
|
release_db_connection(conn)
|
||||||
|
return JSONResponse(content={"success": False, "message": str(e)}, status_code=500)
|
||||||
|
|
||||||
|
@app.post("/api/update-user")
|
||||||
|
def update_user(req: UpdateUserRequest):
|
||||||
|
"""
|
||||||
|
仅覆盖已有数据的编辑接口:
|
||||||
|
- 总是更新基础表 gsdh_data
|
||||||
|
- 仅当存在对应的 checkin_info 时覆盖更新该记录
|
||||||
|
- 不会新增新的 checkin_info 记录(编辑不触发创建)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
conn = get_db_connection()
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
# 1. Update gsdh_data (Base info)
|
||||||
|
cur.execute("""
|
||||||
|
UPDATE gsdh_data
|
||||||
|
SET name = %s, industry_company = %s, is_signed = %s, fee = %s, payment_channel = %s
|
||||||
|
WHERE new_id = %s
|
||||||
|
""", (req.name, req.company_name, req.is_signed, req.fee, req.payment_channel, req.gsdh_id))
|
||||||
|
|
||||||
|
# 2. Check if checkin_info exists
|
||||||
|
cur.execute("SELECT 1 FROM checkin_info WHERE gsdh_id = %s", (req.gsdh_id,))
|
||||||
|
exists = cur.fetchone()
|
||||||
|
|
||||||
|
if exists:
|
||||||
|
# Update existing checkin_info
|
||||||
|
cur.execute("""
|
||||||
|
UPDATE checkin_info
|
||||||
|
SET name = %s, company_name = %s, position = %s, business_scope = %s, vision_2026 = %s, location = %s
|
||||||
|
WHERE gsdh_id = %s
|
||||||
|
""", (req.name, req.company_name, req.position, req.business_scope, req.vision_2026, req.location, req.gsdh_id))
|
||||||
|
msg = "更新成功"
|
||||||
|
else:
|
||||||
|
# Do not create new checkin_info on edit, but return success for base info update
|
||||||
|
msg = "基础信息已更新(未签到用户暂存部分详情)"
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
cur.close()
|
||||||
|
release_db_connection(conn)
|
||||||
|
|
||||||
|
return {"success": True, "message": msg}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
if 'conn' in locals() and conn:
|
||||||
|
conn.rollback()
|
||||||
|
release_db_connection(conn)
|
||||||
|
return JSONResponse(content={"success": False, "message": str(e)}, status_code=500)
|
||||||
|
|
||||||
|
@app.post("/api/uncheckin")
|
||||||
|
def uncheckin_user(req: UncheckinRequest):
|
||||||
|
"""
|
||||||
|
取消签到并覆盖基础信息:
|
||||||
|
- 删除该用户的 checkin_info 记录
|
||||||
|
- 将 gsdh_data.is_signed 置为 FALSE
|
||||||
|
- 可选同时更新 gsdh_data 的 name、industry_company
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
conn = get_db_connection()
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
# 删除签到记录
|
||||||
|
cur.execute("DELETE FROM checkin_info WHERE gsdh_id = %s", (req.gsdh_id,))
|
||||||
|
|
||||||
|
# 更新基础信息与签到状态
|
||||||
|
if req.name is not None and req.company_name is not None:
|
||||||
|
cur.execute("""
|
||||||
|
UPDATE gsdh_data
|
||||||
|
SET name = %s, industry_company = %s, is_signed = 'FALSE'
|
||||||
|
WHERE new_id = %s
|
||||||
|
""", (req.name, req.company_name, req.gsdh_id))
|
||||||
|
elif req.name is not None:
|
||||||
|
cur.execute("""
|
||||||
|
UPDATE gsdh_data
|
||||||
|
SET name = %s, is_signed = 'FALSE'
|
||||||
|
WHERE new_id = %s
|
||||||
|
""", (req.name, req.gsdh_id))
|
||||||
|
elif req.company_name is not None:
|
||||||
|
cur.execute("""
|
||||||
|
UPDATE gsdh_data
|
||||||
|
SET industry_company = %s, is_signed = 'FALSE'
|
||||||
|
WHERE new_id = %s
|
||||||
|
""", (req.company_name, req.gsdh_id))
|
||||||
|
else:
|
||||||
|
cur.execute("UPDATE gsdh_data SET is_signed = 'FALSE' WHERE new_id = %s", (req.gsdh_id,))
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
cur.close()
|
||||||
|
release_db_connection(conn)
|
||||||
|
return {"success": True, "message": "已取消签到并更新基础信息"}
|
||||||
|
except Exception as e:
|
||||||
|
if 'conn' in locals() and conn:
|
||||||
|
conn.rollback()
|
||||||
|
release_db_connection(conn)
|
||||||
|
return JSONResponse(content={"success": False, "message": f"取消签到失败: {str(e)}"}, status_code=500)
|
||||||
|
|
||||||
@app.post("/api/add-user")
|
@app.post("/api/add-user")
|
||||||
def add_user_api(user_data: AddUserRequest):
|
def add_user_api(user_data: AddUserRequest):
|
||||||
@@ -759,6 +1047,125 @@ def add_user_api(user_data: AddUserRequest):
|
|||||||
release_db_connection(conn)
|
release_db_connection(conn)
|
||||||
return JSONResponse(content={"success": False, "message": f"添加失败: {str(e)}"}, status_code=500)
|
return JSONResponse(content={"success": False, "message": f"添加失败: {str(e)}"}, status_code=500)
|
||||||
|
|
||||||
|
@app.get("/admin", response_class=HTMLResponse)
|
||||||
|
async def admin_page(request: Request):
|
||||||
|
"""
|
||||||
|
渲染管理后台页面。
|
||||||
|
"""
|
||||||
|
secret = os.getenv("ADD_USER_SECRET", "123quant-speed")
|
||||||
|
return templates.TemplateResponse("admin.html", {"request": request, "secret": secret})
|
||||||
|
|
||||||
|
@app.get("/api/admin/config")
|
||||||
|
def get_config():
|
||||||
|
"""
|
||||||
|
获取当前的配置信息。
|
||||||
|
"""
|
||||||
|
return load_config()
|
||||||
|
|
||||||
|
@app.post("/api/admin/config")
|
||||||
|
def update_config(config: Dict):
|
||||||
|
"""
|
||||||
|
更新配置信息并保存到文件。
|
||||||
|
"""
|
||||||
|
global CONFIG
|
||||||
|
save_config(config)
|
||||||
|
CONFIG = config
|
||||||
|
# Re-initialize DB pool with new config
|
||||||
|
init_db_pool()
|
||||||
|
return {"success": True}
|
||||||
|
|
||||||
|
@app.post("/api/admin/test-db")
|
||||||
|
def test_db_connection(config: Dict):
|
||||||
|
"""
|
||||||
|
测试数据库连接
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
conn = psycopg2.connect(
|
||||||
|
host=config.get("db_host"),
|
||||||
|
port=config.get("db_port"),
|
||||||
|
user=config.get("db_user"),
|
||||||
|
password=config.get("db_password"),
|
||||||
|
database=config.get("db_name"),
|
||||||
|
connect_timeout=5
|
||||||
|
)
|
||||||
|
conn.close()
|
||||||
|
return {"success": True, "message": "连接成功"}
|
||||||
|
except Exception as e:
|
||||||
|
return JSONResponse(content={"success": False, "message": str(e)}, status_code=400)
|
||||||
|
|
||||||
|
@app.post("/api/admin/upload")
|
||||||
|
async def upload_image(file: UploadFile = File(...)):
|
||||||
|
"""
|
||||||
|
上传图片文件到 static 目录。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
file_location = f"static/{file.filename}"
|
||||||
|
with open(file_location, "wb+") as file_object:
|
||||||
|
shutil.copyfileobj(file.file, file_object)
|
||||||
|
return {"success": True, "url": f"/static/{file.filename}"}
|
||||||
|
except Exception as e:
|
||||||
|
return JSONResponse(content={"success": False, "message": str(e)}, status_code=500)
|
||||||
|
|
||||||
|
@app.post("/api/admin/reset-db")
|
||||||
|
def reset_database():
|
||||||
|
"""
|
||||||
|
重置数据库:删除并重新创建 gsdh_data 和 checkin_info 表。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
conn = get_db_connection()
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
# Drop existing tables
|
||||||
|
cur.execute("DROP TABLE IF EXISTS checkin_info CASCADE;")
|
||||||
|
cur.execute("DROP TABLE IF EXISTS gsdh_data CASCADE;")
|
||||||
|
|
||||||
|
# Recreate tables
|
||||||
|
# gsdh_data
|
||||||
|
cur.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS gsdh_data (
|
||||||
|
new_id VARCHAR(50) PRIMARY KEY,
|
||||||
|
name VARCHAR(100),
|
||||||
|
phone VARCHAR(20) UNIQUE,
|
||||||
|
industry_company VARCHAR(200),
|
||||||
|
fee VARCHAR(50),
|
||||||
|
payment_channel VARCHAR(50),
|
||||||
|
is_signed VARCHAR(10) DEFAULT 'FALSE'
|
||||||
|
);
|
||||||
|
""")
|
||||||
|
|
||||||
|
# checkin_info
|
||||||
|
cur.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS checkin_info (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
gsdh_id VARCHAR(50) REFERENCES gsdh_data(new_id),
|
||||||
|
name VARCHAR(100),
|
||||||
|
phone VARCHAR(20),
|
||||||
|
company_name VARCHAR(200),
|
||||||
|
position VARCHAR(100),
|
||||||
|
business_scope TEXT,
|
||||||
|
vision_2026 TEXT,
|
||||||
|
location VARCHAR(50),
|
||||||
|
social_point INTEGER DEFAULT 5,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Initial Data (Optional - add a test user)
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO gsdh_data (new_id, name, phone, industry_company, fee, payment_channel, is_signed)
|
||||||
|
VALUES ('1', '测试用户', '13800000000', '科技', '0', 'test', 'FALSE');
|
||||||
|
""")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
cur.close()
|
||||||
|
release_db_connection(conn)
|
||||||
|
return {"success": True, "message": "数据库已重置"}
|
||||||
|
except Exception as e:
|
||||||
|
if 'conn' in locals() and conn:
|
||||||
|
conn.rollback()
|
||||||
|
release_db_connection(conn)
|
||||||
|
return JSONResponse(content={"success": False, "message": str(e)}, status_code=500)
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import uvicorn
|
import uvicorn
|
||||||
import argparse
|
import argparse
|
||||||
|
|||||||
@@ -254,7 +254,7 @@
|
|||||||
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<header>
|
<header>
|
||||||
<img src="/static/image.jpg" alt="云南AI共生大会" class="header-img" onerror="this.style.display='none'">
|
<img src="{{ config.header_image }}" alt="{{ config.event_title }}" class="header-img" onerror="this.style.display='none'">
|
||||||
<h1>添加新嘉宾</h1>
|
<h1>添加新嘉宾</h1>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
704
templates/admin.html
Normal file
704
templates/admin.html
Normal file
@@ -0,0 +1,704 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>管理后台 - 云南AI共生大会</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--primary-color: #00f2ff;
|
||||||
|
--secondary-color: #0066ff;
|
||||||
|
--bg-color: #050814;
|
||||||
|
--card-bg: rgba(12, 24, 50, 0.5);
|
||||||
|
--text-color: #ffffff;
|
||||||
|
--text-muted: #b0c4de;
|
||||||
|
--accent-glow: rgba(0, 242, 255, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||||
|
background-color: var(--bg-color);
|
||||||
|
color: var(--text-color);
|
||||||
|
background-image:
|
||||||
|
radial-gradient(circle at 50% 0%, #1a3a75 0%, #050814 60%),
|
||||||
|
radial-gradient(circle at 85% 30%, rgba(0, 242, 255, 0.1) 0%, transparent 40%),
|
||||||
|
radial-gradient(circle at 15% 70%, rgba(0, 102, 255, 0.15) 0%, transparent 40%);
|
||||||
|
background-attachment: fixed;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
overflow-x: hidden;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 800px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 2rem;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
text-align: center;
|
||||||
|
background: linear-gradient(180deg, #ffffff 0%, #d0eaff 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
font-weight: 900;
|
||||||
|
text-shadow: 0 0 20px rgba(0, 242, 255, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--card-bg);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 30px;
|
||||||
|
margin-bottom: 25px;
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
-webkit-backdrop-filter: blur(20px);
|
||||||
|
border: 1px solid rgba(0, 242, 255, 0.2);
|
||||||
|
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
color: #fff;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
padding-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: var(--primary-color);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"], input[type="password"], textarea, select, input[type="number"] {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px;
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
border: 1px solid rgba(0, 242, 255, 0.3);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: white;
|
||||||
|
font-size: 1rem;
|
||||||
|
outline: none;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus, textarea:focus {
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
box-shadow: 0 0 10px rgba(0, 242, 255, 0.3);
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="color"] {
|
||||||
|
height: 40px;
|
||||||
|
width: 100px;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
background: linear-gradient(90deg, #0051ff 0%, #00f2ff 100%);
|
||||||
|
color: white;
|
||||||
|
padding: 12px 24px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: bold;
|
||||||
|
transition: all 0.3s;
|
||||||
|
box-shadow: 0 4px 15px rgba(0, 242, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 6px 20px rgba(0, 242, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button variants */
|
||||||
|
.btn-success {
|
||||||
|
background: linear-gradient(90deg, #28a745 0%, #20c997 100%);
|
||||||
|
box-shadow: 0 4px 15px rgba(32, 201, 151, 0.35);
|
||||||
|
}
|
||||||
|
.btn-danger,
|
||||||
|
.danger-btn {
|
||||||
|
background: linear-gradient(90deg, #cc0000 0%, #ff4d4d 100%);
|
||||||
|
box-shadow: 0 4px 15px rgba(255, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
.btn-block {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.btn-lg {
|
||||||
|
padding: 16px 28px;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
box-shadow: 0 0 20px rgba(0, 242, 255, 0.4);
|
||||||
|
}
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger-zone {
|
||||||
|
border: 1px solid rgba(255, 50, 50, 0.5);
|
||||||
|
background: rgba(50, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger-zone h2 {
|
||||||
|
color: #ff6b6b;
|
||||||
|
border-color: rgba(255, 50, 50, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover,
|
||||||
|
.danger-btn:hover {
|
||||||
|
box-shadow: 0 6px 20px rgba(255, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
margin-top: 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgba(0, 242, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#message {
|
||||||
|
position: fixed;
|
||||||
|
top: 20px;
|
||||||
|
right: 20px;
|
||||||
|
padding: 15px 25px;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: white;
|
||||||
|
font-weight: bold;
|
||||||
|
display: none;
|
||||||
|
z-index: 1000;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
box-shadow: 0 4px 15px rgba(0,0,0,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.success {
|
||||||
|
background: rgba(40, 167, 69, 0.9);
|
||||||
|
border: 1px solid #28a745;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
background: rgba(220, 53, 69, 0.9);
|
||||||
|
border: 1px solid #dc3545;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Password Overlay */
|
||||||
|
#password-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: var(--bg-color);
|
||||||
|
z-index: 9999;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background-image: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card {
|
||||||
|
background: var(--card-bg);
|
||||||
|
padding: 40px;
|
||||||
|
border-radius: 16px;
|
||||||
|
text-align: center;
|
||||||
|
width: 90%;
|
||||||
|
max-width: 400px;
|
||||||
|
box-shadow: 0 0 40px rgba(0,0,0,0.5);
|
||||||
|
border: 1px solid rgba(0, 242, 255, 0.3);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Password input group */
|
||||||
|
.input-group {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.input-group .toggle-btn {
|
||||||
|
position: absolute;
|
||||||
|
right: 10px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
background: rgba(0,0,0,0.3);
|
||||||
|
border: 1px solid rgba(0, 242, 255, 0.3);
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
.input-group .toggle-btn:hover {
|
||||||
|
background: rgba(0,0,0,0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-wrapper {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-wrapper input[type="checkbox"] {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
margin-right: 10px;
|
||||||
|
accent-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-wrapper label {
|
||||||
|
margin-bottom: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- Password Overlay -->
|
||||||
|
<div id="password-overlay">
|
||||||
|
<div class="auth-card">
|
||||||
|
<h2 style="border:none; text-align:center; font-size:1.5rem;">请输入访问密钥</h2>
|
||||||
|
<div class="input-group" style="margin-bottom: 20px;">
|
||||||
|
<input type="password" id="secret-input" placeholder="输入密钥">
|
||||||
|
<button class="toggle-btn" aria-label="显示/隐藏密钥" data-target="secret-input" onclick="togglePasswordVisibility(this)">显示</button>
|
||||||
|
</div>
|
||||||
|
<button onclick="checkSecret()">确认进入</button>
|
||||||
|
<p id="secret-error" style="color: #ff6b6b; margin-top: 15px; display: none;">密钥错误</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<h1>管理后台</h1>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>基本信息设置</h2>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>活动标题</label>
|
||||||
|
<input type="text" id="event_title">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>副标题</label>
|
||||||
|
<input type="text" id="event_sub_title">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>活动时间</label>
|
||||||
|
<input type="text" id="event_time">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>活动地点</label>
|
||||||
|
<input type="text" id="event_location">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>活动内容</label>
|
||||||
|
<textarea id="event_content" rows="4"></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>签到设置</h2>
|
||||||
|
<div class="checkbox-wrapper">
|
||||||
|
<input type="checkbox" id="enable_ticket_validation">
|
||||||
|
<label for="enable_ticket_validation">开启验票验证 (核对数据库与付款记录)</label>
|
||||||
|
</div>
|
||||||
|
<p style="font-size: 0.9em; color: var(--text-muted); margin-left: 30px;">
|
||||||
|
关闭后将跳过数据库核查,直接允许签到(若用户不存在将自动创建)。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>座位设置</h2>
|
||||||
|
<div class="checkbox-wrapper">
|
||||||
|
<input type="checkbox" id="enable_seating" onchange="toggleSeatingInputs()">
|
||||||
|
<label for="enable_seating">开启分桌功能</label>
|
||||||
|
</div>
|
||||||
|
<div id="seating_inputs">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>总桌数</label>
|
||||||
|
<input type="number" id="total_tables" min="1">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>每桌最大人数</label>
|
||||||
|
<input type="number" id="max_per_table" min="1">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>表单字段设置</h2>
|
||||||
|
<table style="width: 100%; border-collapse: collapse; color: white;">
|
||||||
|
<thead>
|
||||||
|
<tr style="border-bottom: 1px solid rgba(255,255,255,0.1); text-align: left;">
|
||||||
|
<th style="padding: 10px;">字段名称</th>
|
||||||
|
<th style="padding: 10px;">是否显示</th>
|
||||||
|
<th style="padding: 10px;">是否必填</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="field_config_body">
|
||||||
|
<!-- Fields will be injected here -->
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>数据库设置</h2>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>数据库地址 (Host)</label>
|
||||||
|
<input type="text" id="db_host" placeholder="localhost">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>端口 (Port)</label>
|
||||||
|
<input type="text" id="db_port" placeholder="5432">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>用户名 (User)</label>
|
||||||
|
<input type="text" id="db_user">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>密码 (Password)</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="password" id="db_password" placeholder="数据库密码">
|
||||||
|
<button class="toggle-btn" aria-label="显示/隐藏密码" data-target="db_password" onclick="togglePasswordVisibility(this)">显示</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>数据库名 (Database Name)</label>
|
||||||
|
<input type="text" id="db_name">
|
||||||
|
</div>
|
||||||
|
<button class="btn-success" onclick="testDbConnection()">测试连接</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>主题设置</h2>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>主色调 (Primary Color)</label>
|
||||||
|
<input type="color" id="primary_color">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>次色调 (Secondary Color)</label>
|
||||||
|
<input type="color" id="secondary_color">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>背景色 (Background Color)</label>
|
||||||
|
<input type="color" id="bg_color">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>图片设置</h2>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>顶部头图</label>
|
||||||
|
<input type="file" id="header_image_file" accept="image/*" style="padding: 10px 0;">
|
||||||
|
<img id="image_preview" class="preview-img" src="">
|
||||||
|
</div>
|
||||||
|
<button onclick="uploadImage()">上传图片</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-bottom: 25px; text-align: center;">
|
||||||
|
<button onclick="saveConfig()" class="btn-block btn-lg">保存所有设置</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card danger-zone">
|
||||||
|
<h2>危险区域</h2>
|
||||||
|
<p style="color: #ffcccc; margin-bottom: 15px;">此处操作不可逆,请谨慎操作。</p>
|
||||||
|
<div class="form-group">
|
||||||
|
<button class="btn-danger" onclick="resetDatabase()">重置/创建数据库</button>
|
||||||
|
<p style="font-size: 0.9em; color: #ffcccc; margin-top: 10px;">这将清空所有签到数据并重新创建表结构。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="message"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Use secret from backend
|
||||||
|
const ACCESS_SECRET = "{{ secret }}";
|
||||||
|
|
||||||
|
// Password Check Logic
|
||||||
|
function checkSecret() {
|
||||||
|
const input = document.getElementById('secret-input');
|
||||||
|
const error = document.getElementById('secret-error');
|
||||||
|
const overlay = document.getElementById('password-overlay');
|
||||||
|
|
||||||
|
if (input.value === ACCESS_SECRET) {
|
||||||
|
overlay.style.opacity = '0';
|
||||||
|
setTimeout(() => {
|
||||||
|
overlay.style.display = 'none';
|
||||||
|
}, 500);
|
||||||
|
} else {
|
||||||
|
error.style.display = 'block';
|
||||||
|
input.value = '';
|
||||||
|
input.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('secret-input').addEventListener('keypress', function (e) {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
checkSecret();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 切换密码输入框的可见性
|
||||||
|
* @param {HTMLButtonElement} btn 触发按钮(需携带 data-target 指向 input 的 id)
|
||||||
|
*/
|
||||||
|
function togglePasswordVisibility(btn) {
|
||||||
|
const targetId = btn.dataset.target;
|
||||||
|
const input = document.getElementById(targetId);
|
||||||
|
if (!input) return;
|
||||||
|
const isHidden = input.type === 'password';
|
||||||
|
input.type = isHidden ? 'text' : 'password';
|
||||||
|
btn.textContent = isHidden ? '隐藏' : '显示';
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSeatingInputs() {
|
||||||
|
const enabled = document.getElementById('enable_seating').checked;
|
||||||
|
const inputs = document.getElementById('seating_inputs');
|
||||||
|
if (enabled) {
|
||||||
|
inputs.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
inputs.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load config on startup
|
||||||
|
fetch('/api/admin/config')
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(config => {
|
||||||
|
document.getElementById('event_title').value = config.event_title;
|
||||||
|
document.getElementById('event_sub_title').value = config.event_sub_title;
|
||||||
|
document.getElementById('event_time').value = config.event_time;
|
||||||
|
document.getElementById('event_location').value = config.event_location;
|
||||||
|
document.getElementById('event_content').value = config.event_content;
|
||||||
|
document.getElementById('primary_color').value = config.primary_color;
|
||||||
|
document.getElementById('secondary_color').value = config.secondary_color;
|
||||||
|
document.getElementById('bg_color').value = config.bg_color;
|
||||||
|
document.getElementById('image_preview').src = config.header_image;
|
||||||
|
|
||||||
|
// Checkin Config
|
||||||
|
document.getElementById('enable_ticket_validation').checked = config.enable_ticket_validation !== false;
|
||||||
|
|
||||||
|
// Seating Config
|
||||||
|
document.getElementById('enable_seating').checked = config.enable_seating !== false; // Default true
|
||||||
|
document.getElementById('total_tables').value = config.total_tables || 14;
|
||||||
|
document.getElementById('max_per_table').value = config.max_per_table || 10;
|
||||||
|
toggleSeatingInputs();
|
||||||
|
|
||||||
|
// Field Config
|
||||||
|
renderFieldConfig(config.field_config || {
|
||||||
|
"name": {"label": "姓名", "show": true, "required": true},
|
||||||
|
"phone": {"label": "手机号码", "show": true, "required": true},
|
||||||
|
"company_name": {"label": "单位名称", "show": true, "required": false},
|
||||||
|
"position": {"label": "职务", "show": true, "required": false},
|
||||||
|
"business_scope": {"label": "公司主要经营 / 业务", "show": true, "required": false},
|
||||||
|
"vision_2026": {"label": "2026年业务愿景", "show": true, "required": false}
|
||||||
|
});
|
||||||
|
|
||||||
|
// DB Config
|
||||||
|
document.getElementById('db_host').value = config.db_host || '';
|
||||||
|
document.getElementById('db_port').value = config.db_port || '';
|
||||||
|
document.getElementById('db_user').value = config.db_user || '';
|
||||||
|
document.getElementById('db_password').value = config.db_password || '';
|
||||||
|
document.getElementById('db_name').value = config.db_name || '';
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderFieldConfig(fieldConfig) {
|
||||||
|
const tbody = document.getElementById('field_config_body');
|
||||||
|
tbody.innerHTML = '';
|
||||||
|
|
||||||
|
// Order of fields
|
||||||
|
const orderedKeys = ["name", "phone", "company_name", "position", "business_scope", "vision_2026"];
|
||||||
|
|
||||||
|
orderedKeys.forEach(key => {
|
||||||
|
if (!fieldConfig[key]) return;
|
||||||
|
const field = fieldConfig[key];
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
tr.style.borderBottom = '1px solid rgba(255,255,255,0.05)';
|
||||||
|
|
||||||
|
// Name/Phone lock logic (can modify required/show but maybe should lock show for logic safety?
|
||||||
|
// User asked to set displayed/required, so I will allow it but maybe warn or just allow it.)
|
||||||
|
// Actually, name/phone are critical. I will disable the 'show' checkbox for them to prevent system break.
|
||||||
|
const isLocked = (key === 'name' || key === 'phone');
|
||||||
|
|
||||||
|
tr.innerHTML = `
|
||||||
|
<td style="padding: 10px;">${field.label} (${key})</td>
|
||||||
|
<td style="padding: 10px;">
|
||||||
|
<input type="checkbox" class="field-show" data-key="${key}" ${field.show ? 'checked' : ''} ${isLocked ? 'disabled' : ''}>
|
||||||
|
</td>
|
||||||
|
<td style="padding: 10px;">
|
||||||
|
<input type="checkbox" class="field-required" data-key="${key}" ${field.required ? 'checked' : ''} ${isLocked ? 'disabled' : ''}>
|
||||||
|
</td>
|
||||||
|
`;
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFieldConfig() {
|
||||||
|
const config = {};
|
||||||
|
const rows = document.querySelectorAll('#field_config_body tr');
|
||||||
|
rows.forEach(row => {
|
||||||
|
const showCb = row.querySelector('.field-show');
|
||||||
|
const reqCb = row.querySelector('.field-required');
|
||||||
|
const key = showCb.dataset.key;
|
||||||
|
|
||||||
|
// Retrieve label from somewhere or just hardcode/store in data attr.
|
||||||
|
// Simple way: text content
|
||||||
|
const labelText = row.cells[0].textContent.split(' (')[0];
|
||||||
|
|
||||||
|
config[key] = {
|
||||||
|
label: labelText,
|
||||||
|
show: showCb.checked,
|
||||||
|
required: reqCb.checked
|
||||||
|
};
|
||||||
|
});
|
||||||
|
// Ensure locked fields are true/true if disabled
|
||||||
|
if (config.name) { config.name.show = true; config.name.required = true; }
|
||||||
|
if (config.phone) { config.phone.show = true; config.phone.required = true; }
|
||||||
|
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showMessage(text, type) {
|
||||||
|
const msg = document.getElementById('message');
|
||||||
|
msg.textContent = text;
|
||||||
|
msg.className = type;
|
||||||
|
msg.style.display = 'block';
|
||||||
|
setTimeout(() => msg.style.display = 'none', 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveConfig() {
|
||||||
|
const config = {
|
||||||
|
event_title: document.getElementById('event_title').value,
|
||||||
|
event_sub_title: document.getElementById('event_sub_title').value,
|
||||||
|
event_time: document.getElementById('event_time').value,
|
||||||
|
event_location: document.getElementById('event_location').value,
|
||||||
|
event_content: document.getElementById('event_content').value,
|
||||||
|
primary_color: document.getElementById('primary_color').value,
|
||||||
|
secondary_color: document.getElementById('secondary_color').value,
|
||||||
|
bg_color: document.getElementById('bg_color').value,
|
||||||
|
header_image: document.getElementById('image_preview').src.replace(window.location.origin, ''),
|
||||||
|
|
||||||
|
// Checkin Config
|
||||||
|
enable_ticket_validation: document.getElementById('enable_ticket_validation').checked,
|
||||||
|
|
||||||
|
// Seating Config
|
||||||
|
enable_seating: document.getElementById('enable_seating').checked,
|
||||||
|
total_tables: parseInt(document.getElementById('total_tables').value),
|
||||||
|
max_per_table: parseInt(document.getElementById('max_per_table').value),
|
||||||
|
|
||||||
|
// Field Config
|
||||||
|
field_config: getFieldConfig(),
|
||||||
|
|
||||||
|
// DB Config
|
||||||
|
db_host: document.getElementById('db_host').value,
|
||||||
|
db_port: document.getElementById('db_port').value,
|
||||||
|
db_user: document.getElementById('db_user').value,
|
||||||
|
db_password: document.getElementById('db_password').value,
|
||||||
|
db_name: document.getElementById('db_name').value
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/config', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify(config)
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
showMessage('设置已保存', 'success');
|
||||||
|
} else {
|
||||||
|
showMessage('保存失败', 'error');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showMessage('网络错误', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testDbConnection() {
|
||||||
|
const config = {
|
||||||
|
db_host: document.getElementById('db_host').value,
|
||||||
|
db_port: document.getElementById('db_port').value,
|
||||||
|
db_user: document.getElementById('db_user').value,
|
||||||
|
db_password: document.getElementById('db_password').value,
|
||||||
|
db_name: document.getElementById('db_name').value
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/test-db', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify(config)
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok) {
|
||||||
|
showMessage('连接成功', 'success');
|
||||||
|
} else {
|
||||||
|
showMessage('连接失败: ' + data.message, 'error');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showMessage('网络错误', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadImage() {
|
||||||
|
const fileInput = document.getElementById('header_image_file');
|
||||||
|
if (!fileInput.files[0]) {
|
||||||
|
showMessage('请先选择图片', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', fileInput.files[0]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/upload', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok) {
|
||||||
|
document.getElementById('image_preview').src = data.url;
|
||||||
|
showMessage('图片上传成功,请记得点击保存设置', 'success');
|
||||||
|
} else {
|
||||||
|
showMessage('上传失败: ' + data.message, 'error');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showMessage('网络错误', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetDatabase() {
|
||||||
|
if (!confirm('确定要重置数据库吗?所有数据将被清空!')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/reset-db', {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok) {
|
||||||
|
showMessage('数据库已重置', 'success');
|
||||||
|
} else {
|
||||||
|
showMessage('重置失败: ' + data.message, 'error');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showMessage('网络错误', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
622
templates/edit.html
Normal file
622
templates/edit.html
Normal file
@@ -0,0 +1,622 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{{ config.event_title }} - 信息修改</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--primary-color: {{ config.primary_color }};
|
||||||
|
--secondary-color: {{ config.secondary_color }};
|
||||||
|
--bg-color: {{ config.bg_color }};
|
||||||
|
--card-bg: rgba(12, 24, 50, 0.5); /* Glassy blue tint */
|
||||||
|
--text-color: #ffffff;
|
||||||
|
--text-muted: #b0c4de; /* Light blue-grey */
|
||||||
|
--accent-glow: rgba(0, 242, 255, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||||
|
background-color: var(--bg-color);
|
||||||
|
color: var(--text-color);
|
||||||
|
/* Deep radial gradient mimicking the poster's atmosphere */
|
||||||
|
background-image:
|
||||||
|
radial-gradient(circle at 50% 0%, #1a3a75 0%, #050814 60%),
|
||||||
|
radial-gradient(circle at 85% 30%, rgba(0, 242, 255, 0.1) 0%, transparent 40%),
|
||||||
|
radial-gradient(circle at 15% 70%, rgba(0, 102, 255, 0.15) 0%, transparent 40%);
|
||||||
|
background-attachment: fixed;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Add a subtle grid or tech-line overlay if desired, but keep it clean for now */
|
||||||
|
body::before {
|
||||||
|
content: '';
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background:
|
||||||
|
linear-gradient(rgba(255,255,255,0.03) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, rgba(255,255,255,0.03) 1px, transparent 1px);
|
||||||
|
background-size: 50px 50px;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 600px;
|
||||||
|
padding: 30px 20px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
padding-bottom: 20px;
|
||||||
|
border-bottom: 1px solid rgba(0, 242, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-img {
|
||||||
|
width: 100%;
|
||||||
|
max-height: 220px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-bottom: 25px;
|
||||||
|
box-shadow: 0 0 20px rgba(0, 242, 255, 0.2);
|
||||||
|
border: 1px solid rgba(0, 242, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 2.4rem;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
/* Poster style: Bold white/cyan gradient text */
|
||||||
|
background: linear-gradient(180deg, #ffffff 0%, #d0eaff 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
text-shadow: 0 0 30px rgba(0, 242, 255, 0.4);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--card-bg);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 30px;
|
||||||
|
margin-bottom: 25px;
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
-webkit-backdrop-filter: blur(20px);
|
||||||
|
border: 1px solid rgba(0, 242, 255, 0.2);
|
||||||
|
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.5), inset 0 0 20px rgba(0, 242, 255, 0.05);
|
||||||
|
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover {
|
||||||
|
box-shadow: 0 8px 40px 0 rgba(0, 0, 0, 0.6), inset 0 0 20px rgba(0, 242, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
color: #fff;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 25px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group {
|
||||||
|
margin-bottom: 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: var(--primary-color);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
input, textarea, select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 14px 16px;
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
border: 1px solid rgba(0, 242, 255, 0.3);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: white;
|
||||||
|
font-size: 1rem;
|
||||||
|
outline: none;
|
||||||
|
transition: all 0.3s;
|
||||||
|
box-shadow: inset 0 2px 5px rgba(0,0,0,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus, textarea:focus {
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
box-shadow: 0 0 15px rgba(0, 242, 255, 0.3), inset 0 2px 5px rgba(0,0,0,0.3);
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Placeholder styling */
|
||||||
|
::placeholder {
|
||||||
|
color: rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
width: 100%;
|
||||||
|
padding: 16px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
/* Gradient button */
|
||||||
|
background: linear-gradient(90deg, #0051ff 0%, #00f2ff 100%);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
box-shadow: 0 4px 15px rgba(0, 242, 255, 0.3);
|
||||||
|
text-shadow: 0 1px 2px rgba(0,0,0,0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 6px 20px rgba(0, 242, 255, 0.5);
|
||||||
|
background: linear-gradient(90deg, #0040cc 0%, #00d0dd 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
button:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
background: #2a3b55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.6;
|
||||||
|
box-shadow: none;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-msg {
|
||||||
|
color: #ff6b6b;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-top: 15px;
|
||||||
|
text-align: center;
|
||||||
|
background: rgba(255, 107, 107, 0.1);
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid rgba(255, 107, 107, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-msg {
|
||||||
|
color: #00ff88;
|
||||||
|
font-size: 1.8rem;
|
||||||
|
text-align: center;
|
||||||
|
margin: 20px 0 10px;
|
||||||
|
text-shadow: 0 0 10px rgba(0, 255, 136, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading spinner */
|
||||||
|
.loader {
|
||||||
|
border: 3px solid rgba(255,255,255,0.1);
|
||||||
|
border-radius: 50%;
|
||||||
|
border-top: 3px solid var(--primary-color);
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: middle;
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
0% { transform: rotate(0deg); }
|
||||||
|
100% { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-select-item {
|
||||||
|
padding: 15px;
|
||||||
|
background: rgba(255,255,255,0.05);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-select-item:hover {
|
||||||
|
background: rgba(0, 242, 255, 0.1);
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
|
<style>
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
border-bottom: 2px solid rgba(255,255,255,0.1);
|
||||||
|
}
|
||||||
|
.tab {
|
||||||
|
padding: 10px 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0.6;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
.tab.active {
|
||||||
|
opacity: 1;
|
||||||
|
border-bottom: 2px solid var(--primary-color);
|
||||||
|
margin-bottom: -2px;
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
.tab-content {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.tab-content.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<header>
|
||||||
|
<img src="{{ config.header_image }}" alt="{{ config.event_title }}" class="header-img" onerror="this.style.display='none'">
|
||||||
|
<div style="text-align: center; margin-bottom: 10px; color: var(--primary-color); font-size: 0.8rem; letter-spacing: 2px; font-weight: 600; opacity: 0.9;">
|
||||||
|
{{ config.event_sub_title }}
|
||||||
|
</div>
|
||||||
|
<h1>{{ config.event_title }}</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Step 1: Search -->
|
||||||
|
<div id="step-search" class="card">
|
||||||
|
<h2 style="margin-bottom: 20px;">修改信息</h2>
|
||||||
|
<div class="input-group">
|
||||||
|
<label>请输入手机号码</label>
|
||||||
|
<input type="text" id="search-input" placeholder="例如:13800000000">
|
||||||
|
</div>
|
||||||
|
<button onclick="searchUser()" id="search-btn">查询</button>
|
||||||
|
<div id="search-error" class="error-msg hidden"></div>
|
||||||
|
<div id="user-list" class="hidden" style="margin-top: 15px;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 2: Edit Info -->
|
||||||
|
<div id="step-form" class="card hidden">
|
||||||
|
<h2 style="margin-bottom: 20px;">编辑信息</h2>
|
||||||
|
|
||||||
|
<div class="tabs">
|
||||||
|
<div class="tab active" onclick="switchTab('signup')">报名信息</div>
|
||||||
|
<div class="tab" onclick="switchTab('checkin')">签到信息</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="edit-form" onsubmit="submitEdit(event)">
|
||||||
|
<input type="hidden" id="gsdh-id">
|
||||||
|
<input type="hidden" id="current-is-signed" value="FALSE">
|
||||||
|
|
||||||
|
<!-- Tab: Signup Info -->
|
||||||
|
<div id="tab-signup" class="tab-content active">
|
||||||
|
<div class="input-group">
|
||||||
|
<label>姓名</label>
|
||||||
|
<input type="text" id="form-name" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="input-group">
|
||||||
|
<label>手机号码 (不可修改)</label>
|
||||||
|
<input type="text" id="form-phone" required disabled style="opacity: 0.7;">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="input-group">
|
||||||
|
<label>单位名称</label>
|
||||||
|
<input type="text" id="form-company" placeholder="点此输入">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="input-group">
|
||||||
|
<label>费用 (Fee)</label>
|
||||||
|
<input type="text" id="form-fee" placeholder="例如:0">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="input-group">
|
||||||
|
<label>支付渠道</label>
|
||||||
|
<input type="text" id="form-payment" placeholder="例如:wechat">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab: Checkin Info -->
|
||||||
|
<div id="tab-checkin" class="tab-content">
|
||||||
|
<div class="input-group">
|
||||||
|
<label>职务</label>
|
||||||
|
<input type="text" id="form-position" placeholder="点此输入">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="input-group">
|
||||||
|
<label>签到状态</label>
|
||||||
|
<select id="form-is-signed">
|
||||||
|
<option value="FALSE">未签到</option>
|
||||||
|
<option value="TRUE">已签到</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="input-group">
|
||||||
|
<label>座位号</label>
|
||||||
|
<input type="text" id="form-location" placeholder="例如:第1桌 (仅已签到有效)">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="input-group">
|
||||||
|
<label>公司主要经营 / 业务</label>
|
||||||
|
<textarea id="form-business" rows="2" placeholder="点此输入"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="input-group">
|
||||||
|
<label>2026年业务愿景</label>
|
||||||
|
<textarea id="form-vision" rows="3" placeholder="点此输入 (为了业务更好对接,最好表述清晰)"></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" id="submit-btn" style="margin-top: 20px;">保存修改</button>
|
||||||
|
<button type="button" onclick="resetFlow()" style="margin-top: 15px; background: transparent; border: 1px solid rgba(255,255,255,0.2);">取消</button>
|
||||||
|
</form>
|
||||||
|
<div id="submit-error" class="error-msg hidden"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 3: Success -->
|
||||||
|
<div id="step-success" class="card hidden" style="text-align: center; padding: 40px 20px;">
|
||||||
|
<div style="font-size: 4rem; margin-bottom: 20px;">✅</div>
|
||||||
|
<h2 class="success-msg">修改成功!</h2>
|
||||||
|
<p style="color: var(--text-muted); margin-bottom: 20px;">您的信息已更新</p>
|
||||||
|
|
||||||
|
<button onclick="window.location.href='/'" style="margin-top: 30px;">返回首页</button>
|
||||||
|
<button onclick="resetFlow()" style="margin-top: 15px; background: transparent; border: 1px solid rgba(255,255,255,0.2);">继续修改</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
async function searchUser() {
|
||||||
|
const query = document.getElementById('search-input').value.trim();
|
||||||
|
const btn = document.getElementById('search-btn');
|
||||||
|
const errorDiv = document.getElementById('search-error');
|
||||||
|
const listDiv = document.getElementById('user-list');
|
||||||
|
|
||||||
|
if (!query) {
|
||||||
|
errorDiv.textContent = "请输入查询内容";
|
||||||
|
errorDiv.classList.remove('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset UI
|
||||||
|
errorDiv.classList.add('hidden');
|
||||||
|
listDiv.classList.add('hidden');
|
||||||
|
listDiv.innerHTML = '';
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<span class="loader"></span> 查询中...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Reuse existing search API
|
||||||
|
const response = await fetch(`/api/search?query=${encodeURIComponent(query)}`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
if (data.multiple) {
|
||||||
|
// Handle multiple results
|
||||||
|
listDiv.classList.remove('hidden');
|
||||||
|
listDiv.innerHTML = '<p style="margin-bottom:10px; color:var(--text-muted)">查询到多位嘉宾,请选择:</p>';
|
||||||
|
data.users.forEach(user => {
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'user-select-item';
|
||||||
|
item.innerHTML = `<strong>${user.name}</strong> <span style="font-size:0.8em; opacity:0.7">(${user.phone})</span>`;
|
||||||
|
item.onclick = () => selectUser(user);
|
||||||
|
listDiv.appendChild(item);
|
||||||
|
});
|
||||||
|
} else if (data.found) {
|
||||||
|
// Single user found
|
||||||
|
// Need to handle both signed and unsigned cases
|
||||||
|
// api/search returns "user" object in both cases
|
||||||
|
// if signed, "checkin_info" might be needed.
|
||||||
|
// The /api/search response structure for signed user:
|
||||||
|
// { found: true, user: {...}, already_signed: true, seat: ..., tablemates: ... }
|
||||||
|
// Ideally we should fetch full details if already signed.
|
||||||
|
// Let's modify selectUser to handle this.
|
||||||
|
selectUser(data.user, data.already_signed);
|
||||||
|
} else {
|
||||||
|
errorDiv.textContent = "未找到该用户";
|
||||||
|
errorDiv.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
errorDiv.textContent = data.message || "查询失败";
|
||||||
|
errorDiv.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
errorDiv.textContent = "网络错误,请稍后重试";
|
||||||
|
errorDiv.classList.remove('hidden');
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = '查询';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectUser(user, alreadySigned) {
|
||||||
|
// Populate form
|
||||||
|
document.getElementById('gsdh-id').value = user.new_id;
|
||||||
|
document.getElementById('form-name').value = user.name;
|
||||||
|
document.getElementById('form-phone').value = user.phone;
|
||||||
|
|
||||||
|
// If user is already signed, we should try to get their checkin info if possible.
|
||||||
|
// However, /api/search currently doesn't return the full checkin_info fields (like position, business_scope) in the 'user' object directly?
|
||||||
|
// Wait, looking at main.py:
|
||||||
|
// if signed: returns "user": user (from gsdh_data)
|
||||||
|
// It doesn't return checkin_info details in 'user'.
|
||||||
|
// We might need to fetch current details.
|
||||||
|
// But wait, if they are signed, /api/search returns { ... seat: ..., tablemates: ... }
|
||||||
|
// It doesn't return the fields we want to edit (position, vision_2026 etc).
|
||||||
|
// We might need to create a new endpoint to get full details OR rely on what we have.
|
||||||
|
// Actually, let's use the 'industry_company' from gsdh_data as initial value for company.
|
||||||
|
|
||||||
|
document.getElementById('form-company').value = user.industry_company || '';
|
||||||
|
|
||||||
|
// If we want to support editing existing checkin info, we need to fetch it.
|
||||||
|
// I'll add a call to get user details if already signed.
|
||||||
|
// We fetch details anyway to get signed status and location
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/get-user-details?phone=${user.phone}`);
|
||||||
|
if (res.ok) {
|
||||||
|
const details = await res.json();
|
||||||
|
if (details.success) {
|
||||||
|
document.getElementById('form-company').value = details.data.company_name || user.industry_company || '';
|
||||||
|
document.getElementById('form-position').value = details.data.position || '';
|
||||||
|
document.getElementById('form-business').value = details.data.business_scope || '';
|
||||||
|
document.getElementById('form-vision').value = details.data.vision_2026 || '';
|
||||||
|
document.getElementById('form-is-signed').value = details.data.is_signed || 'FALSE';
|
||||||
|
document.getElementById('current-is-signed').value = details.data.is_signed || 'FALSE';
|
||||||
|
document.getElementById('form-location').value = details.data.location || '';
|
||||||
|
document.getElementById('form-fee').value = details.data.fee || '';
|
||||||
|
document.getElementById('form-payment').value = details.data.payment_channel || '';
|
||||||
|
|
||||||
|
// Update name if it's different in checkin info
|
||||||
|
if (details.data.name) document.getElementById('form-name').value = details.data.name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to fetch details", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Switch steps
|
||||||
|
document.getElementById('step-search').classList.add('hidden');
|
||||||
|
document.getElementById('step-form').classList.remove('hidden');
|
||||||
|
// Default to signup tab
|
||||||
|
switchTab('signup');
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchTab(tabName) {
|
||||||
|
// Update tabs
|
||||||
|
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||||||
|
if (tabName === 'signup') {
|
||||||
|
document.querySelector('.tab:nth-child(1)').classList.add('active');
|
||||||
|
} else {
|
||||||
|
document.querySelector('.tab:nth-child(2)').classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update content
|
||||||
|
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
|
||||||
|
document.getElementById(`tab-${tabName}`).classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitEdit(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const btn = document.getElementById('submit-btn');
|
||||||
|
const errorDiv = document.getElementById('submit-error');
|
||||||
|
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<span class="loader"></span> 保存中...';
|
||||||
|
errorDiv.classList.add('hidden');
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
gsdh_id: document.getElementById('gsdh-id').value,
|
||||||
|
name: document.getElementById('form-name').value,
|
||||||
|
phone: document.getElementById('form-phone').value,
|
||||||
|
company_name: document.getElementById('form-company').value,
|
||||||
|
position: document.getElementById('form-position').value,
|
||||||
|
business_scope: document.getElementById('form-business').value,
|
||||||
|
vision_2026: document.getElementById('form-vision').value,
|
||||||
|
is_signed: document.getElementById('form-is-signed').value,
|
||||||
|
location: document.getElementById('form-location').value,
|
||||||
|
fee: document.getElementById('form-fee').value,
|
||||||
|
payment_channel: document.getElementById('form-payment').value
|
||||||
|
};
|
||||||
|
const currentSigned = document.getElementById('current-is-signed').value || 'FALSE';
|
||||||
|
const desiredSigned = payload.is_signed || 'FALSE';
|
||||||
|
|
||||||
|
try {
|
||||||
|
let checkinSeat = null;
|
||||||
|
if (currentSigned !== desiredSigned) {
|
||||||
|
if (desiredSigned === 'TRUE') {
|
||||||
|
// 执行签到(创建签到记录并分配座位)
|
||||||
|
const resCheckin = await fetch('/api/checkin', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
const dataCheckin = await resCheckin.json();
|
||||||
|
if (!(resCheckin.ok && dataCheckin.success)) {
|
||||||
|
throw new Error(dataCheckin.message || '签到失败');
|
||||||
|
}
|
||||||
|
checkinSeat = dataCheckin.seat || null;
|
||||||
|
// 更新当前状态缓存
|
||||||
|
document.getElementById('current-is-signed').value = 'TRUE';
|
||||||
|
document.getElementById('form-is-signed').value = 'TRUE';
|
||||||
|
} else {
|
||||||
|
// 取消签到(删除签到记录并置为未签到)
|
||||||
|
const resUncheck = await fetch('/api/uncheckin', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
gsdh_id: payload.gsdh_id,
|
||||||
|
name: payload.name,
|
||||||
|
company_name: payload.company_name
|
||||||
|
})
|
||||||
|
});
|
||||||
|
const dataUncheck = await resUncheck.json();
|
||||||
|
if (!(resUncheck.ok && dataUncheck.success)) {
|
||||||
|
throw new Error(dataUncheck.message || '取消签到失败');
|
||||||
|
}
|
||||||
|
// 更新当前状态缓存
|
||||||
|
document.getElementById('current-is-signed').value = 'FALSE';
|
||||||
|
document.getElementById('form-is-signed').value = 'FALSE';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 覆盖更新其余信息(不新增)
|
||||||
|
const payloadForUpdate = { ...payload };
|
||||||
|
if (desiredSigned === 'TRUE') {
|
||||||
|
// 避免覆盖空座位:若表单未填座位,则保留签到自动分配的座位
|
||||||
|
if (!payloadForUpdate.location && checkinSeat) {
|
||||||
|
payloadForUpdate.location = checkinSeat;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch('/api/update-user', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payloadForUpdate)
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.ok && data.success) {
|
||||||
|
document.getElementById('step-form').classList.add('hidden');
|
||||||
|
document.getElementById('step-success').classList.remove('hidden');
|
||||||
|
} else {
|
||||||
|
errorDiv.textContent = data.message || "保存失败";
|
||||||
|
errorDiv.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
errorDiv.textContent = "网络错误,请稍后重试";
|
||||||
|
errorDiv.classList.remove('hidden');
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = '保存修改';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetFlow() {
|
||||||
|
document.getElementById('step-success').classList.add('hidden');
|
||||||
|
document.getElementById('step-form').classList.add('hidden');
|
||||||
|
document.getElementById('step-search').classList.remove('hidden');
|
||||||
|
document.getElementById('search-input').value = '';
|
||||||
|
document.getElementById('user-list').innerHTML = '';
|
||||||
|
document.getElementById('edit-form').reset();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -3,12 +3,12 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>云南AI共生大会 - 签到</title>
|
<title>{{ config.event_title }} - 签到</title>
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
--primary-color: #00f2ff; /* Cyan from the AI sphere/text */
|
--primary-color: {{ config.primary_color }};
|
||||||
--secondary-color: #0066ff; /* Rich blue */
|
--secondary-color: {{ config.secondary_color }};
|
||||||
--bg-color: #050814; /* Deep space blue/black */
|
--bg-color: {{ config.bg_color }};
|
||||||
--card-bg: rgba(12, 24, 50, 0.5); /* Glassy blue tint */
|
--card-bg: rgba(12, 24, 50, 0.5); /* Glassy blue tint */
|
||||||
--text-color: #ffffff;
|
--text-color: #ffffff;
|
||||||
--text-muted: #b0c4de; /* Light blue-grey */
|
--text-muted: #b0c4de; /* Light blue-grey */
|
||||||
@@ -468,15 +468,15 @@
|
|||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<header>
|
<header>
|
||||||
<img src="/static/image.jpg" alt="云南AI共生大会" class="header-img" onerror="this.style.display='none'">
|
<img src="{{ config.header_image }}" alt="{{ config.event_title }}" class="header-img" onerror="this.style.display='none'">
|
||||||
<div style="text-align: center; margin-bottom: 10px; color: var(--primary-color); font-size: 0.8rem; letter-spacing: 2px; font-weight: 600; opacity: 0.9;">
|
<div style="text-align: center; margin-bottom: 10px; color: var(--primary-color); font-size: 0.8rem; letter-spacing: 2px; font-weight: 600; opacity: 0.9;">
|
||||||
2026 INTELLIGENT LEADERSHIP • AI SYMBIOSIS
|
{{ config.event_sub_title }}
|
||||||
</div>
|
</div>
|
||||||
<h1>云南AI共生大会</h1>
|
<h1>{{ config.event_title }}</h1>
|
||||||
<div class="event-info">
|
<div class="event-info">
|
||||||
<p>📅 <strong>时间:</strong>1月10日 下午 2:00</p>
|
<p>📅 <strong>时间:</strong>{{ config.event_time }}</p>
|
||||||
<p>📍 <strong>地点:</strong>金鼎科技园18号平台B座一楼报告厅</p>
|
<p>📍 <strong>地点:</strong>{{ config.event_location }}</p>
|
||||||
<p>💡 <strong>内容:</strong>邀请重磅大咖分享AI在各行业的企业应用及案例,含深度交流环节。</p>
|
<p>💡 <strong>内容:</strong>{{ config.event_content }}</p>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -513,25 +513,35 @@
|
|||||||
<input type="text" id="form-phone" required>
|
<input type="text" id="form-phone" required>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="input-group">
|
{% set fc = config.field_config or {} %}
|
||||||
<label>单位名称</label>
|
|
||||||
<input type="text" id="form-company" placeholder="点此输入">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
{% if fc.company_name and fc.company_name.show %}
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<label>职务</label>
|
<label>{{ fc.company_name.label }}</label>
|
||||||
<input type="text" id="form-position" placeholder="点此输入">
|
<input type="text" id="form-company" placeholder="点此输入" {{ 'required' if fc.company_name.required else '' }}>
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if fc.position and fc.position.show %}
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<label>公司主要经营 / 业务</label>
|
<label>{{ fc.position.label }}</label>
|
||||||
<textarea id="form-business" rows="2" placeholder="点此输入"></textarea>
|
<input type="text" id="form-position" placeholder="点此输入" {{ 'required' if fc.position.required else '' }}>
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if fc.business_scope and fc.business_scope.show %}
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<label>2026年业务愿景</label>
|
<label>{{ fc.business_scope.label }}</label>
|
||||||
<textarea id="form-vision" rows="3" placeholder="点此输入 (为了业务更好对接,最好表述清晰)"></textarea>
|
<textarea id="form-business" rows="2" placeholder="点此输入" {{ 'required' if fc.business_scope.required else '' }}></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if fc.vision_2026 and fc.vision_2026.show %}
|
||||||
|
<div class="input-group">
|
||||||
|
<label>{{ fc.vision_2026.label }}</label>
|
||||||
|
<textarea id="form-vision" rows="3" placeholder="点此输入 (为了业务更好对接,最好表述清晰)" {{ 'required' if fc.vision_2026.required else '' }}></textarea>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<input type="hidden" id="form-location" value="">
|
<input type="hidden" id="form-location" value="">
|
||||||
|
|
||||||
@@ -544,7 +554,7 @@
|
|||||||
<div id="step-success" class="card hidden" style="text-align: center; padding: 40px 20px;">
|
<div id="step-success" class="card hidden" style="text-align: center; padding: 40px 20px;">
|
||||||
<div style="font-size: 4rem; margin-bottom: 20px;">🎉</div>
|
<div style="font-size: 4rem; margin-bottom: 20px;">🎉</div>
|
||||||
<h2 class="success-msg">签到成功!</h2>
|
<h2 class="success-msg">签到成功!</h2>
|
||||||
<p style="color: var(--text-muted); margin-bottom: 20px;">您的座位已分配</p>
|
<p id="success-seat-label" style="color: var(--text-muted); margin-bottom: 20px;">您的座位已分配</p>
|
||||||
|
|
||||||
<div id="seat-display" class="seat-info">
|
<div id="seat-display" class="seat-info">
|
||||||
<!-- Seat number will be inserted here -->
|
<!-- Seat number will be inserted here -->
|
||||||
@@ -569,7 +579,7 @@
|
|||||||
<h3 id="signed-name" style="margin-bottom: 5px;"></h3>
|
<h3 id="signed-name" style="margin-bottom: 5px;"></h3>
|
||||||
<p id="signed-phone" style="opacity: 0.7;"></p>
|
<p id="signed-phone" style="opacity: 0.7;"></p>
|
||||||
</div>
|
</div>
|
||||||
<p style="color: var(--text-muted); margin-bottom: 20px;">您的座位是</p>
|
<p id="signed-seat-label" style="color: var(--text-muted); margin-bottom: 20px;">您的座位是</p>
|
||||||
|
|
||||||
<div id="signed-seat-display" class="seat-info">
|
<div id="signed-seat-display" class="seat-info">
|
||||||
<!-- Seat number will be inserted here -->
|
<!-- Seat number will be inserted here -->
|
||||||
@@ -622,6 +632,8 @@
|
|||||||
<script>
|
<script>
|
||||||
// Store selected user data
|
// Store selected user data
|
||||||
let currentUser = null;
|
let currentUser = null;
|
||||||
|
// Inject Config
|
||||||
|
const CONFIG = {{ config | tojson }};
|
||||||
|
|
||||||
async function searchUser() {
|
async function searchUser() {
|
||||||
const query = document.getElementById('search-input').value.trim();
|
const query = document.getElementById('search-input').value.trim();
|
||||||
@@ -648,6 +660,24 @@
|
|||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
|
if (data.allow_create) {
|
||||||
|
const u = data.user || {};
|
||||||
|
const tempId = 'TEMP_' + Date.now();
|
||||||
|
currentUser = { new_id: tempId };
|
||||||
|
|
||||||
|
document.getElementById('gsdh-id').value = tempId;
|
||||||
|
document.getElementById('form-name').value = u.name || (!/^\d+$/.test(query) ? query : '');
|
||||||
|
document.getElementById('form-phone').value = u.phone || (/^\d+$/.test(query) ? query : '');
|
||||||
|
|
||||||
|
document.getElementById('display-name').textContent = document.getElementById('form-name').value || '新嘉宾';
|
||||||
|
document.getElementById('display-phone').textContent = document.getElementById('form-phone').value;
|
||||||
|
document.getElementById('display-company').textContent = '现场注册';
|
||||||
|
|
||||||
|
document.getElementById('step-search').classList.add('hidden');
|
||||||
|
document.getElementById('step-form').classList.remove('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (data.multiple) {
|
if (data.multiple) {
|
||||||
// Handle multiple results
|
// Handle multiple results
|
||||||
listDiv.classList.remove('hidden');
|
listDiv.classList.remove('hidden');
|
||||||
@@ -666,7 +696,12 @@
|
|||||||
|
|
||||||
document.getElementById('signed-name').textContent = data.user.name;
|
document.getElementById('signed-name').textContent = data.user.name;
|
||||||
document.getElementById('signed-phone').textContent = data.user.phone;
|
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 = data.seat || "自由席";
|
||||||
|
document.getElementById('signed-seat-display').style.display = 'block';
|
||||||
|
document.getElementById('signed-seat-label').textContent = "您的座位是";
|
||||||
|
document.getElementById('signed-seat-label').style.display = 'block';
|
||||||
|
|
||||||
// Show tablemates if available
|
// Show tablemates if available
|
||||||
if (data.tablemates && data.tablemates.length > 0) {
|
if (data.tablemates && data.tablemates.length > 0) {
|
||||||
@@ -674,6 +709,11 @@
|
|||||||
} else {
|
} else {
|
||||||
document.getElementById('signed-tablemates-container').classList.add('hidden');
|
document.getElementById('signed-tablemates-container').classList.add('hidden');
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
document.getElementById('signed-seat-display').style.display = 'none';
|
||||||
|
document.getElementById('signed-seat-label').textContent = "签到成功,请自由入座";
|
||||||
|
document.getElementById('signed-tablemates-container').classList.add('hidden');
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Single user found
|
// Single user found
|
||||||
selectUser(data.user);
|
selectUser(data.user);
|
||||||
@@ -730,15 +770,21 @@
|
|||||||
btn.innerHTML = '<span class="loader"></span> 提交中...';
|
btn.innerHTML = '<span class="loader"></span> 提交中...';
|
||||||
errorDiv.classList.add('hidden');
|
errorDiv.classList.add('hidden');
|
||||||
|
|
||||||
|
// Helper to safe get value
|
||||||
|
const getVal = (id) => {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
return el ? el.value : '';
|
||||||
|
};
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
gsdh_id: document.getElementById('gsdh-id').value,
|
gsdh_id: getVal('gsdh-id'),
|
||||||
name: document.getElementById('form-name').value,
|
name: getVal('form-name'),
|
||||||
phone: document.getElementById('form-phone').value,
|
phone: getVal('form-phone'),
|
||||||
company_name: document.getElementById('form-company').value,
|
company_name: getVal('form-company'),
|
||||||
position: document.getElementById('form-position').value,
|
position: getVal('form-position'),
|
||||||
business_scope: document.getElementById('form-business').value,
|
business_scope: getVal('form-business'),
|
||||||
vision_2026: document.getElementById('form-vision').value,
|
vision_2026: getVal('form-vision'),
|
||||||
location: document.getElementById('form-location').value
|
location: getVal('form-location')
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -753,8 +799,12 @@
|
|||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
|
if (CONFIG.enable_seating !== false) {
|
||||||
// Show assigned seat
|
// Show assigned seat
|
||||||
document.getElementById('seat-display').textContent = data.seat || "自由席";
|
document.getElementById('seat-display').textContent = data.seat || "自由席";
|
||||||
|
document.getElementById('seat-display').style.display = 'block';
|
||||||
|
document.getElementById('success-seat-label').textContent = "您的座位已分配";
|
||||||
|
document.getElementById('success-seat-label').style.display = 'block';
|
||||||
|
|
||||||
// Show tablemates if available
|
// Show tablemates if available
|
||||||
if (data.tablemates && data.tablemates.length > 0) {
|
if (data.tablemates && data.tablemates.length > 0) {
|
||||||
@@ -762,6 +812,11 @@
|
|||||||
} else {
|
} else {
|
||||||
document.getElementById('tablemates-container').classList.add('hidden');
|
document.getElementById('tablemates-container').classList.add('hidden');
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
document.getElementById('seat-display').style.display = 'none';
|
||||||
|
document.getElementById('success-seat-label').textContent = "请自由入座";
|
||||||
|
document.getElementById('tablemates-container').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
document.getElementById('step-form').classList.add('hidden');
|
document.getElementById('step-form').classList.add('hidden');
|
||||||
document.getElementById('step-success').classList.remove('hidden');
|
document.getElementById('step-success').classList.remove('hidden');
|
||||||
|
|||||||
@@ -9,9 +9,9 @@
|
|||||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
|
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
--primary-color: #00f2ff; /* Cyan from the AI sphere/text */
|
--primary-color: {{ config.primary_color }};
|
||||||
--secondary-color: #0066ff; /* Rich blue */
|
--secondary-color: {{ config.secondary_color }};
|
||||||
--bg-color: #050814; /* Deep space blue/black */
|
--bg-color: {{ config.bg_color }};
|
||||||
--card-bg: rgba(12, 24, 50, 0.5); /* Glassy blue tint */
|
--card-bg: rgba(12, 24, 50, 0.5); /* Glassy blue tint */
|
||||||
--text-color: #ffffff;
|
--text-color: #ffffff;
|
||||||
--text-muted: #b0c4de; /* Light blue-grey */
|
--text-muted: #b0c4de; /* Light blue-grey */
|
||||||
@@ -172,7 +172,7 @@
|
|||||||
<body>
|
<body>
|
||||||
<div id="app" class="container pb-20">
|
<div id="app" class="container pb-20">
|
||||||
<!-- Header Image -->
|
<!-- Header Image -->
|
||||||
<img src="/static/image.jpg" alt="Header Image" class="header-img" onerror="this.style.display='none'">
|
<img src="{{ config.header_image }}" alt="{{ config.event_title }}" class="header-img" onerror="this.style.display='none'">
|
||||||
|
|
||||||
<!-- Header Info -->
|
<!-- Header Info -->
|
||||||
<div class="glass-card p-6 mb-6 text-center relative overflow-hidden">
|
<div class="glass-card p-6 mb-6 text-center relative overflow-hidden">
|
||||||
|
|||||||
Reference in New Issue
Block a user