Compare commits

...

10 Commits

Author SHA1 Message Date
jeremygan2021
c6bfc59faa new
Some checks failed
Docker 构建与部署 / deploy (push) Has been cancelled
Docker 构建与部署 / build (push) Has been cancelled
2026-03-20 15:13:14 +08:00
jeremygan2021
730007f121 完成页面签到一致性以及手机验证 2026-01-28 23:44:15 +08:00
jeremygan2021
da2f7b34ea 完成页面签到一致性以及手机验证 2026-01-28 23:43:12 +08:00
jeremygan2021
4597d6fe35 支付功能 2026-01-28 22:54:03 +08:00
jeremygan2021
4c7f8126e8 数据库和支付页面 2026-01-28 20:46:41 +08:00
jeremygan2021
b97c9f548d 优化泡泡手机端 2026-01-21 22:00:33 +08:00
jeremygan2021
5dad2533df 完善大屏泡泡效果 2026-01-21 21:43:56 +08:00
jeremygan2021
e94bc11083 大屏 2026-01-21 20:50:22 +08:00
jeremygan2021
b5b9d6e731 数据库更新 2026-01-21 18:06:06 +08:00
jeremygan2021
2b70af6652 完成admin 2026-01-21 17:58:31 +08:00
24 changed files with 3307 additions and 176 deletions

184
.gitea/workflows/deploy.yml Normal file
View File

@@ -0,0 +1,184 @@
name: Docker 构建与部署
on:
push:
branches: [main, master]
workflow_dispatch:
inputs:
arch:
description: '构建架构'
required: true
default: 'amd64'
type: choice
options:
- amd64
- arm64
- both
upload_only:
description: '仅上传已存在的tar文件'
required: false
default: 'false'
type: boolean
env:
IMAGE_NAME: checkin_sys
IMAGE_TAG: latest
CONTAINER_NAME: checkin_sys-container
LOCAL_PORT: 8800
CONTAINER_PORT: 8800
jobs:
build:
runs-on: ubuntu
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && !inputs.upload_only)
steps:
- name: 检出代码
uses: actions/checkout@v4
- name: 设置 Buildx
uses: docker/setup-buildx-action@v3
- name: 提取架构信息
id: arch
run: |
if [ "${{ github.event.inputs.arch }}" == "arm64" ]; then
echo "platform=linux/arm64" >> $GITHUB_OUTPUT
echo "arch_suffix=-arm64" >> $GITHUB_OUTPUT
elif [ "${{ github.event.inputs.arch }}" == "both" ]; then
echo "platforms=linux/amd64,linux/arm64" >> $GITHUB_OUTPUT
else
echo "platform=linux/amd64" >> $GITHUB_OUTPUT
echo "arch_suffix=-amd64" >> $GITHUB_OUTPUT
fi
- name: 构建 Docker 镜像
uses: docker/build-push-action@v5
with:
context: .
platforms: ${{ steps.arch.outputs.platforms || 'linux/amd64' }}
push: false
load: true
tags: ${{ env.IMAGE_NAME }}:${{ env.IMAGE_TAG }}
outputs: type=docker,dest=${{ env.IMAGE_NAME }}-${{ env.IMAGE_TAG }}${{ steps.arch.outputs.arch_suffix }}.tar
- name: 上传镜像 artifact
uses: actions/upload-artifact@v4
with:
name: docker-image-${{ steps.arch.outputs.arch_suffix || '-amd64' }}
path: ${{ env.IMAGE_NAME }}-${{ env.IMAGE_TAG }}${{ steps.arch.outputs.arch_suffix }}.tar
retention-days: 1
deploy:
runs-on: ubuntu-latest
needs: build
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
steps:
- name: 检出代码
uses: actions/checkout@v4
- name: 下载镜像 artifact (AMD64)
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && (inputs.arch == 'amd64' || inputs.arch == 'both'))
uses: actions/download-artifact@v4
with:
name: docker-image--amd64
path: .
- name: 下载镜像 artifact (ARM64)
if: github.event_name == 'workflow_dispatch' && inputs.arch == 'both'
uses: actions/download-artifact@v4
with:
name: docker-image--arm64
path: .
- name: 安装 sshpass
run: |
sudo apt-get update
sudo apt-get install -y sshpass
- name: 上传到服务器
env:
SERVER_HOST: ${{ secrets.SERVER_HOST }}
SERVER_USER: ${{ secrets.SERVER_USER }}
SERVER_PASSWORD: ${{ secrets.SERVER_PASSWORD }}
SERVER_PORT: ${{ secrets.SERVER_PORT }}
TAR_FILE: ${{ env.IMAGE_NAME }}-${{ env.IMAGE_TAG }}-amd64.tar
run: |
sshpass -p "$SERVER_PASSWORD" scp -P "$SERVER_PORT" -o StrictHostKeyChecking=no "$TAR_FILE" "${SERVER_USER}@${SERVER_HOST}:/tmp/"
sshpass -p "$SERVER_PASSWORD" scp -P "$SERVER_PORT" -o StrictHostKeyChecking=no ".env" "${SERVER_USER}@${SERVER_HOST}:/tmp/"
- name: 在服务器上部署
env:
SERVER_HOST: ${{ secrets.SERVER_HOST }}
SERVER_USER: ${{ secrets.SERVER_USER }}
SERVER_PASSWORD: ${{ secrets.SERVER_PASSWORD }}
SERVER_PORT: ${{ secrets.SERVER_PORT }}
TAR_FILE: ${{ env.IMAGE_NAME }}-${{ env.IMAGE_TAG }}-amd64.tar
run: |
sshpass -p "$SERVER_PASSWORD" ssh -p "$SERVER_PORT" -o StrictHostKeyChecking=no "${SERVER_USER}@${SERVER_HOST}" << 'EOF'
set -e
run_sudo() {
echo "$SERVER_PASSWORD" | sudo -S -p '' "$@"
}
echo "[INFO] 开始服务器端部署..."
# 检查并停止现有容器
if run_sudo docker ps -a --format 'table {{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
echo "[INFO] 发现现有容器 ${CONTAINER_NAME},正在停止并删除..."
run_sudo docker stop ${CONTAINER_NAME} || true
run_sudo docker rm ${CONTAINER_NAME} || true
fi
# 检查并删除现有镜像
if run_sudo docker images --format 'table {{.Repository}}:{{.Tag}}' | grep -q "^${IMAGE_NAME}:${IMAGE_TAG}$"; then
echo "[INFO] 发现现有镜像 ${IMAGE_NAME}:${IMAGE_TAG},正在删除..."
run_sudo docker rmi ${IMAGE_NAME}:${IMAGE_TAG} || true
fi
# 加载新镜像
echo "[INFO] 加载新镜像..."
run_sudo docker load -i /tmp/${TAR_FILE}
# 验证镜像是否加载成功
if run_sudo docker images | grep -q "${IMAGE_NAME}"; then
echo "[SUCCESS] 镜像加载成功"
else
echo "[ERROR] 镜像加载失败"
exit 1
fi
# 启动新容器
echo "[INFO] 启动新容器..."
run_sudo docker run -d --network host --name ${CONTAINER_NAME} \
--env-file /tmp/.env \
-e DB_HOST=localhost \
-e DB_PORT=5432 \
${IMAGE_NAME}:${IMAGE_TAG}
# 验证容器是否启动成功
if run_sudo docker ps | grep -q "${CONTAINER_NAME}"; then
echo "[SUCCESS] 容器启动成功"
echo "[INFO] 容器状态:"
run_sudo docker ps | grep "${CONTAINER_NAME}"
else
echo "[ERROR] 容器启动失败"
echo "[INFO] 查看容器日志:"
run_sudo docker logs ${CONTAINER_NAME}
exit 1
fi
# 清理临时文件
echo "[INFO] 清理临时文件..."
rm -f /tmp/${TAR_FILE}
rm -f /tmp/.env
echo "[SUCCESS] 部署完成!"
echo "[INFO] 应用访问地址: http://${SERVER_HOST}:${LOCAL_PORT}"
EOF
- name: 清理本地临时文件
run: |
rm -f ${{ env.IMAGE_NAME }}-${{ env.IMAGE_TAG }}-*.tar

View File

@@ -0,0 +1,52 @@
I have analyzed the current project structure and designed a "Check-in Large Screen" (签到大屏) solution that integrates seamlessly with your existing system.
### **Plan Overview**
I will implement a **"Galaxy Spotlight"** style large screen:
1. **Background (Danmaku/Floating Tags)**:
* `Business Scope` and short `Vision` keywords will float elegantly in the background like stars or drifting clouds.
* Uses the project's blue/cyan tech theme.
2. **Foreground (Spotlight Card)**:
* A central, animated card that cycles through checked-in guests.
* Displays: **Company Name** (Prominent), **Guest Name/Position**, and **Vision 2026**.
* Updates every 8-10 seconds to ensure everyone gets exposure.
### **Implementation Steps**
#### 1. Backend (`main.py`)
* Add a new route `/wall` to serve the large screen page.
* Add an API `/api/wall/data` to fetch approved check-in data (filtering out empty entries).
#### 2. Frontend (`templates/wall.html`)
* Create a new responsive HTML template.
* **Visuals**: Reusing the `radial-gradient` background and neon aesthetics from `index.html`.
* **Animation**:
* JS-based floating animation for background tags.
* CSS transitions for the central spotlight card.
#### 3. Admin Integration (`templates/admin.html`)
* Add a button "Open Large Screen" (打开签到大屏) in the Admin Dashboard for easy access.
### **Technical Details**
* **Tech Stack**: Native JS/CSS (no extra heavy libraries needed), integrated into the existing FastAPI app.
* **Data Source**: Reads directly from `checkin_info` table.
* **Compatibility**: Optimized for 1080p/4K displays (MacOS standard).

View File

@@ -0,0 +1,34 @@
I have analyzed the current project structure and designed a "Check-in Large Screen" (签到大屏) solution that integrates seamlessly with your existing system.
### **Plan Overview**
I will implement a **"Galaxy Spotlight"** style large screen:
1. **Background (Danmaku/Floating Tags)**:
- `Business Scope` and short `Vision` keywords will float elegantly in the background like stars or drifting clouds.
- Uses the project's blue/cyan tech theme.
2. **Foreground (Spotlight Card)**:
- A central, animated card that cycles through checked-in guests.
- Displays: **Company Name** (Prominent), **Guest Name/Position**, and **Vision 2026**.
- Updates every 8-10 seconds to ensure everyone gets exposure.
### **Implementation Steps**
#### 1. Backend (`main.py`)
- Add a new route `/wall` to serve the large screen page.
- Add an API `/api/wall/data` to fetch approved check-in data (filtering out empty entries).
#### 2. Frontend (`templates/wall.html`)
- Create a new responsive HTML template.
- **Visuals**: Reusing the `radial-gradient` background and neon aesthetics from `index.html`.
- **Animation**:
- JS-based floating animation for background tags.
- CSS transitions for the central spotlight card.
#### 3. Admin Integration (`templates/admin.html`)
- Add a button "Open Large Screen" (打开签到大屏) in the Admin Dashboard for easy access.
### **Technical Details**
- **Tech Stack**: Native JS/CSS (no extra heavy libraries needed), integrated into the existing FastAPI app.
- **Data Source**: Reads directly from `checkin_info` table.
- **Compatibility**: Optimized for 1080p/4K displays (MacOS standard).

View File

@@ -0,0 +1,37 @@
I will optimize the registration and payment flow with the specific requirement to record the actual payment amount.
### 1. Backend Updates (`main.py`)
- **Database Schema**: Add `out_trade_no` column to `gsdh_data` to link users with payment orders.
- **Pre-payment Validation**:
- Update `create_payment` (H5) and `create_native_payment`.
- Check if phone exists in `gsdh_data`.
- **Logic**:
- If `fee` has a valid amount (not empty, '0', or 'PENDING'), reject as "Already Registered".
- If `fee` is empty/PENDING, allow payment.
- Upsert user with `fee` = 'PENDING' and `payment_channel` = '..._pending'.
- **Payment Status Sync**:
- Update `check_payment_status`:
- When WeChat returns `SUCCESS`:
- **Update `fee`** to the actual configured payment amount (e.g., "0.01").
- Update `payment_channel` to the final channel (removing '_pending').
- Update `out_trade_no` logic to ensure we find the correct user.
### 2. Frontend Optimization (`templates/ticket.html`)
- **Dynamic Form Fields**:
- Replace hardcoded inputs with a loop that renders fields based on `config.field_config` (managed in Admin).
- Respect "Show", "Required", and "Label" settings.
- **QR Code Modal**:
- Add explicit text: "请使用微信扫一扫完成支付" (Please use WeChat Scan to pay).
- Optimize layout.
- **Payment Logic**:
- Handle "Already Registered" error.
- Redirect to Success Page on completion.
### 3. New Success Page
- Create `templates/success.html` to show a "Registration Successful" message.
- Add route in `main.py`.
### 4. Admin Interface
- Verify `templates/admin.html` field configuration works with the new `ticket.html` dynamic rendering.
This ensures that successful payments overwrite the 'PENDING' status with the actual amount paid.

1
.vercel/project.json Normal file
View File

@@ -0,0 +1 @@
{"projectName":"trae_upload_7ilw"}

7
.vercelignore Normal file
View File

@@ -0,0 +1,7 @@
node_modules
build
dist
.git
.trae
.log
.figma

Binary file not shown.

View File

@@ -1,18 +1,46 @@
{
"event_title": "123",
"event_sub_title": "123",
"event_time": "123",
"event_location": "123",
"event_content": "123",
"event_title": "AI共生大会",
"event_sub_title": "云南AI大会",
"event_time": "等待输入",
"event_location": "玉溪青花街三生咖啡酒吧",
"event_content": "等待输入",
"primary_color": "#00f2ff",
"secondary_color": "#0066ff",
"bg_color": "#050814",
"header_image": "/static/image.jpg",
"enable_ticket_validation": false,
"header_image": "/static/image1.jpg",
"enable_ticket_validation": true,
"enable_sms_verification": true,
"enable_payment": true,
"payment_amount": 0.01,
"wechat_pay_config": {
"appid": "wxdf2ca73e6c0929f0",
"mchid": "1723685511",
"api_v3_key": "XishanquBanzhanweixinzhifumiyao3",
"serial_no": "49E299FC2F01841ECD6658D8B037C5338BD170BA",
"private_key_path": "static/cert/apiclient_key.pem",
"notify_url": "https://event.quant-speed.com/ticket/finish/"
},
"enable_seating": false,
"total_tables": 14,
"max_per_table": 10,
"field_config": {
"ticket_field_config": {
"name": {
"label": "姓名",
"show": true,
"required": true
},
"phone": {
"label": "手机号码",
"show": true,
"required": true
},
"industry_company": {
"label": "单位名称",
"show": true,
"required": true
}
},
"checkin_field_config": {
"name": {
"label": "姓名",
"show": true,
@@ -44,9 +72,21 @@
"required": false
}
},
"db_host": "6.6.6.86",
"wall_config": {
"bg_opacity": 0.3,
"show_title": true,
"learn_more_url": "",
"show_fields": {
"name": true,
"company_name": false,
"position": true,
"vision_2026": true,
"business_scope": true
}
},
"db_host": "6.6.6.66",
"db_port": "5432",
"db_user": "gsdh",
"db_password": "123gsdh",
"db_name": "gsdh"
"db_user": "AI_event",
"db_password": "123AI_event",
"db_name": "AI_event"
}

61
diagnose_db.py Normal file
View File

@@ -0,0 +1,61 @@
import psycopg2
import json
import os
from psycopg2.extras import RealDictCursor
def load_config():
if os.path.exists("config.json"):
with open("config.json", "r", encoding="utf-8") as f:
return json.load(f)
return {}
def diagnose():
config = load_config()
print(f"Config Loaded: Host={config.get('db_host')}, DB={config.get('db_name')}, User={config.get('db_user')}")
try:
conn = psycopg2.connect(
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"),
connect_timeout=5
)
cur = conn.cursor()
# 1. List Tables
print("\n=== Tables in 'public' schema ===")
cur.execute("""
SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'public'
""")
tables = cur.fetchall()
for t in tables:
print(f"- {t[0]}")
# 2. Detail Columns for our tables
target_tables = ['gsdh_data', 'checkin_info']
for table in target_tables:
print(f"\n=== Columns for table '{table}' ===")
cur.execute("""
SELECT column_name, data_type, is_nullable
FROM information_schema.columns
WHERE table_name = %s
""", (table,))
columns = cur.fetchall()
if not columns:
print("(Table not found)")
else:
for col in columns:
print(f" - {col[0]}: {col[1]}")
conn.close()
except Exception as e:
print(f"\nConnection Failed: {e}")
if __name__ == "__main__":
diagnose()

View File

@@ -13,10 +13,10 @@
# =============================================================================
# 配置非局域网变量 - 公网上传方法
SERVER_HOST="121.43.104.161" # 服务器IP地址
SERVER_USER="ubuntu" # 服务器用户名
SERVER_PASSWORD="qweasdzxc1" # 服务器密码
SERVER_PORT="6222"
# SERVER_HOST="121.43.104.161" # 服务器IP地址
# SERVER_USER="ubuntu" # 服务器用户名
# SERVER_PASSWORD="qweasdzxc1" # 服务器密码
# SERVER_PORT="6222"
# 配置局域网变量 - 公司局域网上传方法
# SERVER_HOST="6.6.6.86" # 服务器IP地址
@@ -25,6 +25,12 @@ SERVER_PORT="6222"
# SERVER_PORT="22" # SSH端口默认22
SERVER_HOST="6.6.6.66" # 服务器IP地址
SERVER_USER="quant" # 服务器用户名
SERVER_PASSWORD="123quant-speed" # 服务器密码
SERVER_PORT="22"
IMAGE_NAME="checkin_sys" # Docker镜像名称
IMAGE_TAG="latest" # Docker镜像标签
CONTAINER_NAME="checkin_sys-container" # 容器名称
@@ -163,27 +169,32 @@ deploy_on_server() {
sshpass -p "$SERVER_PASSWORD" ssh -p "$SERVER_PORT" -o StrictHostKeyChecking=no "${SERVER_USER}@${SERVER_HOST}" << EOF
set -e
# 定义 sudo 包装函数以处理密码输入
run_sudo() {
echo "$SERVER_PASSWORD" | sudo -S -p '' "\$@"
}
echo "[INFO] 开始服务器端部署..."
# 检查并停止现有容器
if sudo docker ps -a --format 'table {{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
if run_sudo docker ps -a --format 'table {{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
echo "[INFO] 发现现有容器 ${CONTAINER_NAME},正在停止并删除..."
sudo docker stop ${CONTAINER_NAME} || true
sudo docker rm ${CONTAINER_NAME} || true
run_sudo docker stop ${CONTAINER_NAME} || true
run_sudo docker rm ${CONTAINER_NAME} || true
fi
# 检查并删除现有镜像
if sudo docker images --format 'table {{.Repository}}:{{.Tag}}' | grep -q "^${IMAGE_NAME}:${IMAGE_TAG}$"; then
if run_sudo docker images --format 'table {{.Repository}}:{{.Tag}}' | grep -q "^${IMAGE_NAME}:${IMAGE_TAG}$"; then
echo "[INFO] 发现现有镜像 ${IMAGE_NAME}:${IMAGE_TAG},正在删除..."
sudo docker rmi ${IMAGE_NAME}:${IMAGE_TAG} || true
run_sudo docker rmi ${IMAGE_NAME}:${IMAGE_TAG} || true
fi
# 加载新镜像
echo "[INFO] 加载新镜像..."
sudo docker load -i /tmp/${TAR_FILE}
run_sudo docker load -i /tmp/${TAR_FILE}
# 验证镜像是否加载成功
if sudo docker images | grep -q "${IMAGE_NAME}"; then
if run_sudo docker images | grep -q "${IMAGE_NAME}"; then
echo "[SUCCESS] 镜像加载成功"
else
echo "[ERROR] 镜像加载失败"
@@ -194,21 +205,21 @@ deploy_on_server() {
echo "[INFO] 启动新容器..."
# 使用 host 网络模式,让容器直接使用宿主机网络栈,从而可以通过 localhost:5432 访问宿主机数据库
# 同时覆盖环境变量,强制使用本地数据库配置
sudo docker run -d --network host --name ${CONTAINER_NAME} \
run_sudo docker run -d --network host --name ${CONTAINER_NAME} \
--env-file /tmp/.env \
-e DB_HOST=localhost \
-e DB_PORT=5432 \
${IMAGE_NAME}:${IMAGE_TAG}
# 验证容器是否启动成功
if sudo docker ps | grep -q "${CONTAINER_NAME}"; then
if run_sudo docker ps | grep -q "${CONTAINER_NAME}"; then
echo "[SUCCESS] 容器启动成功"
echo "[INFO] 容器状态:"
sudo docker ps | grep "${CONTAINER_NAME}"
run_sudo docker ps | grep "${CONTAINER_NAME}"
else
echo "[ERROR] 容器启动失败"
echo "[INFO] 查看容器日志:"
sudo docker logs ${CONTAINER_NAME}
run_sudo docker logs ${CONTAINER_NAME}
exit 1
fi

771
main.py
View File

@@ -7,12 +7,19 @@ import psycopg2
from psycopg2 import pool
from psycopg2.extras import RealDictCursor
from typing import Optional, List, Dict
import datetime
from datetime import timedelta
import random
import uuid
import os
import json
import shutil
import difflib
import time
import base64
import requests
from cryptography.hazmat.primitives import serialization, hashes
from cryptography.hazmat.primitives.asymmetric import padding
from concurrent.futures import ProcessPoolExecutor
from dotenv import load_dotenv
@@ -35,17 +42,49 @@ DEFAULT_CONFIG = {
"db_user": os.getenv("DB_USER", "gsdh"),
"db_password": os.getenv("DB_PASSWORD", "123gsdh"),
"db_name": os.getenv("DB_NAME", "gsdh"),
"enable_sms_verification": False,
"sms_verification_config": {
"template_code": "SMS_493295002",
"sign_name": "叠加态科技云南"
},
"enable_ticket_validation": True,
"enable_seating": True,
"total_tables": 14,
"max_per_table": 10,
"field_config": {
"ticket_field_config": {
"name": {"label": "姓名", "show": True, "required": True},
"phone": {"label": "手机号码", "show": True, "required": True},
"industry_company": {"label": "单位名称/行业类型", "show": True, "required": True}
},
"checkin_field_config": {
"name": {"label": "姓名", "show": True, "required": True},
"phone": {"label": "手机号码", "show": True, "required": True},
"company_name": {"label": "单位名称", "show": True, "required": False},
"position": {"label": "职务", "show": True, "required": False},
"business_scope": {"label": "公司主要经营 / 业务", "show": True, "required": False},
"vision_2026": {"label": "2026年业务愿景", "show": True, "required": False}
},
"wall_config": {
"show_title": True,
"learn_more_url": "https://www.example.com",
"show_fields": {
"name": True,
"company_name": True,
"position": True,
"vision_2026": True,
"business_scope": True
},
"bg_opacity": 0.3
},
"enable_payment": False,
"payment_amount": 0.01,
"wechat_pay_config": {
"mchid": "",
"appid": "",
"api_v3_key": "",
"serial_no": "",
"private_key_path": "cert/apiclient_key.pem",
"notify_url": "https://your-domain.com/api/payment/notify"
}
}
@@ -57,6 +96,15 @@ def load_config():
for key, value in DEFAULT_CONFIG.items():
if key not in config:
config[key] = value
# Deep merge for wall_config
if "wall_config" in config and isinstance(config["wall_config"], dict):
for k, v in DEFAULT_CONFIG["wall_config"].items():
if k not in config["wall_config"]:
config["wall_config"][k] = v
elif "wall_config" not in config:
config["wall_config"] = DEFAULT_CONFIG["wall_config"]
return config
return DEFAULT_CONFIG
@@ -66,6 +114,9 @@ def save_config(config):
CONFIG = load_config()
# SMS Verification Storage (In-memory)
SMS_CODES = {} # phone -> {code: str, expires_at: datetime}
app = FastAPI()
# 进程池全局变量
@@ -73,11 +124,29 @@ process_pool = None
@app.on_event("startup")
async def startup_event():
import sys
print(f"DEBUG: Running from {__file__}")
print(f"DEBUG: Python executable: {sys.executable}")
global process_pool
# RK3588 有 8 个核心,预留一些给数据库和系统,使用 6 个核心进行计算
process_pool = ProcessPoolExecutor(max_workers=6)
print("ProcessPoolExecutor initialized with 6 workers")
# Debug: Print all registered routes
print("=== Registered Routes ===")
for route in app.routes:
if hasattr(route, "methods"):
print(f"{route.path} {route.methods}")
else:
print(f"{route.path}")
print("=========================")
@app.post("/api/test-sms")
async def test_sms_simple():
return {"message": "Test SMS route is working"}
@app.on_event("shutdown")
async def shutdown_event():
if process_pool:
@@ -265,6 +334,7 @@ class CheckinRequest(BaseModel):
gsdh_id: str
name: str
phone: str
verification_code: Optional[str] = None
company_name: Optional[str] = None
position: Optional[str] = None
business_scope: Optional[str] = None
@@ -274,10 +344,113 @@ class CheckinRequest(BaseModel):
class AddUserRequest(BaseModel):
name: str
phone: str
verification_code: Optional[str] = None
industry_company: Optional[str] = None
fee: Optional[str] = None
payment_channel: Optional[str] = None
class SendSMSRequest(BaseModel):
phone: str
def verify_sms_code(phone: str, code: str) -> bool:
if not CONFIG.get("enable_sms_verification", False):
return True
if not code:
return False
record = SMS_CODES.get(phone)
if not record:
return False
if datetime.datetime.now() > record["expires_at"]:
del SMS_CODES[phone]
return False
if record["code"] == code:
# Optional: Delete after successful verification to prevent replay
# del SMS_CODES[phone]
return True
return False
class VerifyCodeRequest(BaseModel):
phone: str
code: str
@app.post("/api/verify-sms-code")
def verify_sms_code_endpoint(req: VerifyCodeRequest):
if verify_sms_code(req.phone, req.code):
return JSONResponse({"success": True, "message": "验证成功"})
else:
return JSONResponse({"success": False, "message": "验证码错误或已过期"})
@app.api_route("/api/send-sms", methods=["GET", "POST"])
async def send_sms_endpoint(req: Request):
print(f"DEBUG: send_sms_endpoint called via {req.method}")
# Handle GET for testing
if req.method == "GET":
return JSONResponse({"message": "GET method works, please use POST"})
# Handle POST
try:
body = await req.json()
phone = body.get("phone")
except:
return JSONResponse({"success": False, "message": "Invalid JSON body"}, status_code=400)
print(f"DEBUG: Processing phone={phone}")
if not CONFIG.get("enable_sms_verification", False):
return JSONResponse({"success": False, "message": "短信验证未开启"})
if not phone or len(phone) != 11:
return JSONResponse({"success": False, "message": "手机号格式错误"})
# Generate 4 digit code
code = str(random.randint(1000, 9999))
# Store code (expires in 5 minutes)
SMS_CODES[phone] = {
"code": code,
"expires_at": datetime.datetime.now() + timedelta(minutes=5)
}
# Send SMS via external API
sms_config = CONFIG.get("sms_verification_config", {})
payload = {
"phone_number": phone,
"code": code,
"template_code": sms_config.get("template_code", "SMS_493295002"),
"sign_name": sms_config.get("sign_name", "叠加态科技云南")
}
try:
print(f"DEBUG: Sending SMS payload: {payload}")
# Using a timeout to prevent hanging
res = requests.post(
'https://data.tangledup-ai.com/api/send-sms',
json=payload,
headers={'Content-Type': 'application/json', 'accept': 'application/json'},
timeout=30
)
print(f"DEBUG: SMS API Response: {res.status_code} - {res.text}")
if res.status_code == 200:
data = res.json()
if data.get("status") == "success":
return JSONResponse({"success": True, "message": "验证码已发送"})
else:
return JSONResponse({"success": False, "message": data.get("message", "发送失败")})
else:
return JSONResponse({"success": False, "message": f"发送失败: {res.status_code}"})
except Exception as e:
print(f"SMS Send Error: {e}")
return JSONResponse({"success": False, "message": "发送验证码请求失败"})
def get_db_connection():
max_retries = 5
for attempt in range(max_retries):
@@ -691,6 +864,15 @@ def search_user(query: str):
else:
user = users[0]
# Normalize fee to float for frontend consistency
if user.get('fee'):
try:
user['fee'] = float(user['fee'])
except:
user['fee'] = 0.0
else:
user['fee'] = 0.0
# Check if already signed
if user.get('is_signed') == 'TRUE':
# Check if already signed
@@ -735,6 +917,11 @@ def search_user(query: str):
@app.post("/api/checkin")
def checkin_user(checkin_data: CheckinRequest):
# Verify SMS Code if enabled
if CONFIG.get("enable_sms_verification", False):
if not verify_sms_code(checkin_data.phone, checkin_data.verification_code):
return JSONResponse(content={"success": False, "message": "手机验证码错误或已过期"}, status_code=400)
try:
conn = get_db_connection()
cur = conn.cursor()
@@ -1055,6 +1242,44 @@ 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("/wall", response_class=HTMLResponse)
async def wall_page(request: Request):
"""
渲染签到大屏页面。
"""
global CONFIG
CONFIG = load_config()
return templates.TemplateResponse("wall.html", {"request": request, "config": CONFIG})
@app.get("/api/wall/data")
def get_wall_data():
"""
获取大屏所需的数据:
1. 所有已签到用户的 Company Name, Vision, Business Scope
2. 过滤掉空数据
"""
try:
conn = get_db_connection()
cur = conn.cursor(cursor_factory=RealDictCursor)
# 获取最新的签到数据(按时间倒序)
query = """
SELECT gsdh_id, name, company_name, position, business_scope, vision_2026, social_point
FROM checkin_info
ORDER BY created_at DESC
"""
cur.execute(query)
rows = cur.fetchall()
cur.close()
release_db_connection(conn)
return {"success": True, "data": rows}
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.get("/api/admin/config")
def get_config():
"""
@@ -1129,6 +1354,7 @@ def reset_database():
industry_company VARCHAR(200),
fee VARCHAR(50),
payment_channel VARCHAR(50),
out_trade_no VARCHAR(100),
is_signed VARCHAR(10) DEFAULT 'FALSE'
);
""")
@@ -1166,6 +1392,549 @@ def reset_database():
release_db_connection(conn)
return JSONResponse(content={"success": False, "message": str(e)}, status_code=500)
@app.post("/api/admin/init-db")
def init_database():
"""
初始化数据库:在不删除现有数据的情况下创建表结构(如果表不存在)。
适用于迁移到新数据库或修复表结构。
"""
try:
# 1. 强制重新加载配置,确保使用磁盘上最新的 config.json
# 这避免了用户修改文件但未重启服务导致连接旧库的问题
current_config = load_config()
print(f"Initializing DB with config: {current_config.get('db_host')}:{current_config.get('db_port')} DB={current_config.get('db_name')}")
# 2. 直接建立连接,不依赖全局连接池
conn = psycopg2.connect(
host=current_config.get("db_host", "localhost"),
port=current_config.get("db_port", "5432"),
user=current_config.get("db_user", "gsdh"),
password=current_config.get("db_password", "123gsdh"),
database=current_config.get("db_name", "gsdh"),
connect_timeout=10
)
conn.autocommit = False # Ensure transaction
cur = conn.cursor()
# 3. Create Tables (IF NOT EXISTS)
# 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),
out_trade_no VARCHAR(100),
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
);
""")
# 4. Migration: Add missing columns if table exists but column doesn't
# Helper to add column safely
def add_column_if_not_exists(table, column, type_def):
try:
cur.execute(f"ALTER TABLE {table} ADD COLUMN {column} {type_def}")
print(f"Added column {column} to {table}")
except psycopg2.errors.DuplicateColumn:
conn.rollback() # Rollback the sub-transaction error
print(f"Column {column} already exists in {table}")
except Exception as e:
conn.rollback()
print(f"Error adding column {column}: {e}")
# Ensure we are in a valid transaction state
conn.commit()
# Check and add columns for gsdh_data
# We need to handle transaction carefully. ALTER TABLE is transactional in Postgres.
# But if it fails, the transaction is aborted.
# So we check information_schema first to avoid DuplicateColumn error which aborts transaction.
def safe_add_column(table, col, dtype):
cur.execute("""
SELECT column_name FROM information_schema.columns
WHERE table_name = %s AND column_name = %s
""", (table, col))
if not cur.fetchone():
cur.execute(f"ALTER TABLE {table} ADD COLUMN {col} {dtype}")
print(f"Added column {col} to {table}")
safe_add_column('gsdh_data', 'fee', 'VARCHAR(50)')
safe_add_column('gsdh_data', 'payment_channel', 'VARCHAR(50)')
safe_add_column('gsdh_data', 'out_trade_no', 'VARCHAR(100)')
safe_add_column('gsdh_data', 'is_signed', "VARCHAR(10) DEFAULT 'FALSE'")
safe_add_column('checkin_info', 'social_point', 'INTEGER DEFAULT 5')
safe_add_column('checkin_info', 'business_scope', 'TEXT')
safe_add_column('checkin_info', 'vision_2026', 'TEXT')
conn.commit()
cur.close()
conn.close()
# Also update the global pool if needed, but not strictly necessary for this request
# But good practice to ensure subsequent requests use new config
global CONFIG
CONFIG = current_config
init_db_pool()
return {"success": True, "message": f"数据库结构已初始化/迁移成功 (Host: {current_config.get('db_host')})"}
except Exception as e:
if 'conn' in locals() and conn:
conn.rollback()
conn.close()
return JSONResponse(content={"success": False, "message": str(e)}, status_code=500)
# ==========================================
# WeChat Pay V3 & Ticket Logic
# ==========================================
class WeChatPayService:
def __init__(self, config):
self.mchid = config.get("mchid")
self.appid = config.get("appid")
self.api_v3_key = config.get("api_v3_key")
self.serial_no = config.get("serial_no")
self.private_key_path = config.get("private_key_path")
self.notify_url = config.get("notify_url")
self.private_key = self._load_private_key()
def _load_private_key(self):
try:
if not self.private_key_path or not os.path.exists(self.private_key_path):
print(f"Warning: Private key not found at {self.private_key_path}")
return None
with open(self.private_key_path, "rb") as f:
return serialization.load_pem_private_key(f.read(), password=None)
except Exception as e:
print(f"Error loading private key: {e}")
return None
def _generate_signature(self, method, url, timestamp, nonce_str, body):
if not self.private_key:
raise Exception("Private key not loaded")
message = f"{method}\n{url}\n{timestamp}\n{nonce_str}\n{body}\n"
signature = self.private_key.sign(
message.encode("utf-8"),
padding.PKCS1v15(),
hashes.SHA256()
)
return base64.b64encode(signature).decode("utf-8")
def build_authorization_header(self, method, url, body):
timestamp = str(int(time.time()))
nonce_str = str(uuid.uuid4()).replace("-", "")
signature = self._generate_signature(method, url, timestamp, nonce_str, body)
return (
f'WECHATPAY2-SHA256-RSA2048 mchid="{self.mchid}",'
f'nonce_str="{nonce_str}",'
f'signature="{signature}",'
f'timestamp="{timestamp}",'
f'serial_no="{self.serial_no}"'
)
def h5_payment(self, description, out_trade_no, amount_fen, client_ip, payer_openid=None):
url = "https://api.mch.weixin.qq.com/v3/pay/transactions/h5"
path = "/v3/pay/transactions/h5"
data = {
"appid": self.appid,
"mchid": self.mchid,
"description": description,
"out_trade_no": out_trade_no,
"notify_url": self.notify_url,
"amount": {
"total": amount_fen,
"currency": "CNY"
},
"scene_info": {
"payer_client_ip": client_ip,
"h5_info": {
"type": "Wap"
}
}
}
body = json.dumps(data)
auth = self.build_authorization_header("POST", path, body)
headers = {
"Content-Type": "application/json",
"Accept": "application/json",
"Authorization": auth
}
response = requests.post(url, data=body, headers=headers)
if response.status_code in [200, 202]:
return response.json()
else:
raise Exception(f"WeChat Pay Error: {response.text}")
def native_payment(self, description, out_trade_no, amount_fen, client_ip=None):
url = "https://api.mch.weixin.qq.com/v3/pay/transactions/native"
path = "/v3/pay/transactions/native"
data = {
"appid": self.appid,
"mchid": self.mchid,
"description": description,
"out_trade_no": out_trade_no,
"notify_url": self.notify_url,
"amount": {
"total": amount_fen,
"currency": "CNY"
}
}
body = json.dumps(data)
auth = self.build_authorization_header("POST", path, body)
headers = {
"Content-Type": "application/json",
"Accept": "application/json",
"Authorization": auth
}
response = requests.post(url, data=body, headers=headers)
if response.status_code in [200, 202]:
return response.json()
else:
raise Exception(f"WeChat Pay Error: {response.text}")
def query_order(self, out_trade_no):
url = f"https://api.mch.weixin.qq.com/v3/pay/transactions/out-trade-no/{out_trade_no}?mchid={self.mchid}"
path = f"/v3/pay/transactions/out-trade-no/{out_trade_no}?mchid={self.mchid}"
auth = self.build_authorization_header("GET", path, "")
headers = {
"Accept": "application/json",
"Authorization": auth
}
response = requests.get(url, headers=headers)
if response.status_code == 200:
return response.json()
elif response.status_code == 404:
return {"trade_state": "NOTPAY"} # Or handle as not found
else:
raise Exception(f"WeChat Query Error: {response.text}")
@app.get("/ticket", response_class=HTMLResponse)
async def ticket_page(request: Request):
global CONFIG
CONFIG = load_config()
if not CONFIG.get("enable_payment", False):
return HTMLResponse(content="<h1>报名通道尚未开启 / Registration Closed</h1>", status_code=403)
return templates.TemplateResponse("ticket.html", {"request": request, "config": CONFIG})
@app.get("/success", response_class=HTMLResponse)
async def success_page(request: Request):
global CONFIG
CONFIG = load_config()
return templates.TemplateResponse("success.html", {"request": request, "config": CONFIG})
class TicketPaymentRequest(BaseModel):
name: str
phone: str
verification_code: Optional[str] = None
company_name: Optional[str] = None
position: Optional[str] = None
business_scope: Optional[str] = None
vision_2026: Optional[str] = None
@app.post("/api/payment/h5")
async def create_payment(req: TicketPaymentRequest, request: Request):
try:
global CONFIG
CONFIG = load_config()
if not CONFIG.get("enable_payment", False):
return JSONResponse(content={"success": False, "message": "支付未开启"}, status_code=403)
wc_config = CONFIG.get("wechat_pay_config", {})
service = WeChatPayService(wc_config)
# 1. Generate Order ID
out_trade_no = f"TICKET_{int(time.time())}_{uuid.uuid4().hex[:6]}"
# 2. Amount (Convert to Fen)
amount_yuan = float(CONFIG.get("payment_amount", 0.01))
amount_fen = int(amount_yuan * 100)
# 3. Client IP
client_ip = request.client.host
# 4. Save Temporary User Data (Pending Payment)
# We can store this in gsdh_data with is_signed='FALSE' and a special status or just fee='PENDING'
# For simplicity, we create the user now but mark as unpaid/unsigned.
# Check if phone exists first
conn = get_db_connection()
cur = conn.cursor()
# Calculate new ID
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_id = str(max_id + 1)
# Upsert user (if phone exists, update; else insert)
cur.execute("SELECT new_id, fee FROM gsdh_data WHERE phone = %s", (req.phone,))
existing = cur.fetchone()
if existing:
user_id = existing[0]
current_fee = existing[1]
# Validation: Check if already paid
# Conditions for "Unpaid": None, Empty String, '0', 'PENDING'
if current_fee and current_fee not in ['0', 'PENDING']:
cur.close()
release_db_connection(conn)
return JSONResponse(content={"success": False, "message": "该手机号已完成报名,无需重复支付"}, status_code=400)
cur.execute("""
UPDATE gsdh_data
SET name=%s, industry_company=%s, fee='PENDING', payment_channel='wechat_h5_pending', out_trade_no=%s
WHERE new_id=%s
""", (req.name, req.company_name, out_trade_no, user_id))
else:
user_id = new_id
cur.execute("""
INSERT INTO gsdh_data (new_id, name, phone, industry_company, fee, payment_channel, is_signed, out_trade_no)
VALUES (%s, %s, %s, %s, 'PENDING', 'wechat_h5_pending', 'FALSE', %s)
""", (user_id, req.name, req.phone, req.company_name, out_trade_no))
# Also store extra info in checkin_info?
# Requirement: "If registration paid then add to gsdh_data"
# So we strictly only want them in gsdh_data if paid?
# But we need to track the order.
# Let's keep them as "fee='PENDING'" which effectively means not fully valid yet.
conn.commit()
cur.close()
release_db_connection(conn)
# 5. Call WeChat Pay
try:
res = service.h5_payment(
description=f"{CONFIG.get('event_title', 'Event')} Ticket",
out_trade_no=out_trade_no,
amount_fen=amount_fen,
client_ip=client_ip
)
h5_url = res.get("h5_url")
return {"success": True, "h5_url": h5_url, "out_trade_no": out_trade_no}
except Exception as wx_e:
# If payment fails, maybe clean up or log
print(f"WeChat Pay Failed: {wx_e}")
# Mocking for testing if no key provided
if "Private key not loaded" in str(wx_e):
return JSONResponse(content={"success": False, "message": "服务端未配置支付证书,无法发起支付"}, status_code=500)
return JSONResponse(content={"success": False, "message": f"支付请求失败: {str(wx_e)}"}, status_code=500)
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/payment/native")
async def create_native_payment(req: TicketPaymentRequest, request: Request):
"""
处理 Native 支付请求 (扫码支付)。
"""
try:
global CONFIG
CONFIG = load_config()
# Verify SMS Code if enabled
if CONFIG.get("enable_sms_verification", False):
if not verify_sms_code(req.phone, req.verification_code):
return JSONResponse(content={"success": False, "message": "手机验证码错误或已过期"}, status_code=400)
if not CONFIG.get("enable_payment", False):
return JSONResponse(content={"success": False, "message": "支付未开启"}, status_code=403)
wc_config = CONFIG.get("wechat_pay_config", {})
service = WeChatPayService(wc_config)
# 1. Generate Order ID
out_trade_no = f"TICKET_{int(time.time())}_{uuid.uuid4().hex[:6]}"
# 2. Amount (Convert to Fen)
amount_yuan = float(CONFIG.get("payment_amount", 0.01))
amount_fen = int(amount_yuan * 100)
# 3. Save Temporary User Data (Pending Payment)
conn = get_db_connection()
cur = conn.cursor()
# Calculate new ID
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_id = str(max_id + 1)
# Upsert user
cur.execute("SELECT new_id, fee FROM gsdh_data WHERE phone = %s", (req.phone,))
existing = cur.fetchone()
if existing:
user_id = existing[0]
current_fee = existing[1]
# Validation: Check if already paid
if current_fee and current_fee not in ['0', 'PENDING']:
cur.close()
release_db_connection(conn)
return JSONResponse(content={"success": False, "message": "该手机号已完成报名,无需重复支付"}, status_code=400)
cur.execute("""
UPDATE gsdh_data
SET name=%s, industry_company=%s, fee='PENDING', payment_channel='微信线上支付_pending', out_trade_no=%s
WHERE new_id=%s
""", (req.name, req.company_name, out_trade_no, user_id))
else:
user_id = new_id
cur.execute("""
INSERT INTO gsdh_data (new_id, name, phone, industry_company, fee, payment_channel, is_signed, out_trade_no)
VALUES (%s, %s, %s, %s, 'PENDING', '微信线上支付_pending', 'FALSE', %s)
""", (user_id, req.name, req.phone, req.company_name, out_trade_no))
conn.commit()
cur.close()
release_db_connection(conn)
# 4. Call WeChat Pay
try:
res = service.native_payment(
description=f"{CONFIG.get('event_title', 'Event')} Ticket",
out_trade_no=out_trade_no,
amount_fen=amount_fen,
client_ip=request.client.host
)
code_url = res.get("code_url")
return {"success": True, "code_url": code_url, "out_trade_no": out_trade_no}
except Exception as wx_e:
print(f"WeChat Pay Failed: {wx_e}")
if "Private key not loaded" in str(wx_e):
return JSONResponse(content={"success": False, "message": "服务端未配置支付证书,无法发起支付"}, status_code=500)
return JSONResponse(content={"success": False, "message": f"支付请求失败: {str(wx_e)}"}, status_code=500)
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.get("/api/payment/check/{out_trade_no}")
async def check_payment_status(out_trade_no: str):
"""
查询订单支付状态。
"""
try:
global CONFIG
CONFIG = load_config()
wc_config = CONFIG.get("wechat_pay_config", {})
service = WeChatPayService(wc_config)
# Call WeChat Query API
try:
res = service.query_order(out_trade_no)
trade_state = res.get("trade_state")
if trade_state == "SUCCESS":
# Update DB to mark as paid
conn = get_db_connection()
try:
cur = conn.cursor()
# Get actual amount from Config
amount = str(CONFIG.get("payment_amount", 0.01))
# Update gsdh_data
# Remove '_pending' suffix from payment_channel and set actual fee
cur.execute("""
UPDATE gsdh_data
SET fee = %s,
payment_channel = REPLACE(payment_channel, '_pending', '')
WHERE out_trade_no = %s
""", (amount, out_trade_no))
conn.commit()
cur.close()
finally:
release_db_connection(conn)
return {"success": True, "trade_state": trade_state}
except Exception as wx_e:
# Mock for testing without keys
if "Private key not loaded" in str(wx_e):
# Simulate success for testing? No, stick to error.
return {"success": False, "message": "配置错误"}
return {"success": False, "message": str(wx_e)}
except Exception as e:
return JSONResponse(content={"success": False, "message": str(e)}, status_code=500)
@app.post("/api/payment/notify")
async def payment_notify(request: Request):
# Handle WeChat Pay Callback
# 1. Verify Signature (Skip for MVP/Mock)
# 2. Decrypt Resource
# 3. Update Database
try:
body = await request.body()
# Mock logic: Assume success if we get here for now, or parse JSON
data = json.loads(body)
# resource = data.get("resource", {})
# ciphertext = resource.get("ciphertext")
# nonce = resource.get("nonce")
# associated_data = resource.get("associated_data")
# Decrypt logic here...
# Since we can't fully implement decryption without keys/certs,
# we will assume this endpoint receives a valid notification and update the user.
# In a real scenario, we MUST decrypt to get out_trade_no.
# For this task, I'll log it.
print(f"Payment Notify Received: {data}")
return JSONResponse(content={"code": "SUCCESS", "message": "OK"})
except Exception as e:
print(f"Notify Error: {e}")
return JSONResponse(content={"code": "FAIL", "message": "ERROR"}, status_code=500)
if __name__ == "__main__":
import uvicorn
import argparse

View File

@@ -5,3 +5,5 @@ pydantic
jinja2
python-multipart
python-dotenv
requests
cryptography

Binary file not shown.

After

Width:  |  Height:  |  Size: 356 KiB

Binary file not shown.

View File

@@ -0,0 +1,25 @@
-----BEGIN CERTIFICATE-----
MIIEITCCAwmgAwIBAgIUSeKZ/C8BhB7NZljYsDfFM4vRcLowDQYJKoZIhvcNAQEL
BQAwXjELMAkGA1UEBhMCQ04xEzARBgNVBAoTClRlbnBheS5jb20xHTAbBgNVBAsT
FFRlbnBheS5jb20gQ0EgQ2VudGVyMRswGQYDVQQDExJUZW5wYXkuY29tIFJvb3Qg
Q0EwHhcNMjYwMTI4MTQxNTE4WhcNMzEwMTI3MTQxNTE4WjB7MRMwEQYDVQQDDAox
NzIzNjg1NTExMRswGQYDVQQKDBLlvq7kv6HllYbmiLfns7vnu58xJzAlBgNVBAsM
HuS6keWNl+mHj+i/ueenkeaKgOaciemZkOWFrOWPuDELMAkGA1UEBhMCQ04xETAP
BgNVBAcMCFNoZW5aaGVuMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA
xxKpTgk0MAbX/cU2zXn9T6uCWToRJzNXiGf45RIcP9mD7wlXdr3p2HQ6Vv3zhDKE
goj980THhoaqX3mWXs9jxMNtdXPsjrFXk0I0e0O7BqFl/5pVicoDv3GZ/LASdqwC
rLFWZRmmkB45ehb5qoFBTFBmE3SS5qm8v2UbrrRMuu07vM1AHmtw36YyAlGRKaMj
fQVr5l0u9769j2F8Rd8/HlyiyjUZjhpRiGuv/n1eEWoL6ZIFGA/BCPgvU+Q+Xm3q
6aZd8odO5p2c1knlV/dJfRv2vC9Q8fXLalr3UQF0bM0vFCKn2sWLRYFo9Z+Nep51
/S9eUZn+7yhpxoImKBGycQIDAQABo4G5MIG2MAkGA1UdEwQCMAAwCwYDVR0PBAQD
AgP4MIGbBgNVHR8EgZMwgZAwgY2ggYqggYeGgYRodHRwOi8vZXZjYS5pdHJ1cy5j
b20uY24vcHVibGljL2l0cnVzY3JsP0NBPTFCRDQyMjBFNTBEQkMwNEIwNkFEMzk3
NTQ5ODQ2QzAxQzNFOEVCRDImc2c9SEFDQzQ3MUI2NTQyMkUxMkIyN0E5RDMzQTg3
QUQxQ0RGNTkyNkUxNDAzNzEwDQYJKoZIhvcNAQELBQADggEBAEqNn5h/g+PNkbwM
2sIl8aSgtB1tOgBAQH0sI2zqJbP/nEkeqAS1PL7Eo8EwbVrxXQV7rv75+iF8g3ry
DiCGGgooO4F/Ohh3X1BwiSKokzl8sZV6rB/ojHKkfwA0GE9xualqo2sikGpNQyUt
BeDp9OC8puBd7LR8KK8fJ7+3e3xcnH+cWZ6IZCRXgVtlofvM3OuzHQs0Bq31B4fp
yhuX1Dig/gldeKt2S624tUfOaVVdFROikYFKx8vxcsWmKMZnK8M5UBiJJIE/txvK
TT+vRUXdjZ737j4GQ86T6vedn5S88rFFiBv7LsQTIa69CRdD2dGajoTexgh1m+l9
LsHf65A=
-----END CERTIFICATE-----

View File

@@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDHEqlOCTQwBtf9
xTbNef1Pq4JZOhEnM1eIZ/jlEhw/2YPvCVd2venYdDpW/fOEMoSCiP3zRMeGhqpf
eZZez2PEw211c+yOsVeTQjR7Q7sGoWX/mlWJygO/cZn8sBJ2rAKssVZlGaaQHjl6
FvmqgUFMUGYTdJLmqby/ZRuutEy67Tu8zUAea3DfpjICUZEpoyN9BWvmXS73vr2P
YXxF3z8eXKLKNRmOGlGIa6/+fV4RagvpkgUYD8EI+C9T5D5eberppl3yh07mnZzW
SeVX90l9G/a8L1Dx9ctqWvdRAXRszS8UIqfaxYtFgWj1n416nnX9L15Rmf7vKGnG
giYoEbJxAgMBAAECggEAf6NzcllsYK7Cpi9gp/ZjeEWbWeJlRSEiKo+fgiBOhJHO
ieEPd07lNKexED3beRN66sczLFsZIyQM8RJgW4HVkj9LW1dsgUEryXtVcwNGU0YA
xBYMakBgjssj1GSAMh6vyFIb9s6vgRAgAivhAHXHjEqwaCECX2rNXlpRmX55BBAd
nMudGbbnb/jE9EUVOEvovrYqfCCxloHejCEY2/XwmU2GpGkDQIklOv2gnsvAWvDu
V+jx8OlMDeyoAZ65yIdRzgrX20JmxqyXZPOfbleNmR3F03sgpuv5cZSZVgLU8Hd+
CPVDGOV66kupFmsMi5R2rvrExhTp4I0V6s5s+wwNAQKBgQDu7G8BQcUB4NXsKemf
D89e5di0BKK5+MvMDGYTRmEMJJ9Ag1iCD11/4eg6iEei1h6TRTxmvDWm3e20KnrK
8Ug3oJQf8kYYUGgmIDhvWegfbAw1RNoZ9+I7UYofUzFk4pAdJMMqolc0kXIoOPX7
uym/FiDgIqstjNRr+kbLtU8/qQKBgQDVTRU/cE3R0eZMDZ3EvbqD00BzPmK3h3Hs
9358oh3v0Xf69p2TH58gVpbJPbHydeUvbmjPc6QeRFT9pVA7dFqXi3lJ4WOu9vu+
l2Sn0NX+g7khDzig8lj27RWxmAVc5H//VqeSxi8Z87JWGKGn1bkttN8MGvCSn8UA
W3LLfRw5iQKBgQCAmI1etdGdozBA/oYZ8N6Ci1/J9LzJMBow53OBaF4PtR5qEMfw
qOiATk06Q+Oax3iJd7h860xNViH82Ohilt9x4WIYl8QWjiCgdLMra86+Kt+fREHH
vF3t44NePN7XGALNTN4m8l3Rk39IGvB631Am43gqaz6LM8OZoom8VwgikQKBgEFT
4nYTh0ID72zLns5q7X0CbnLdYI6lRRc2ld7GKDUTCpQqhAhTXwEgg/4OjzmbMh5c
ymz/FfzPINiukOwkrrpLVVJzXXxw2Jl+9K0RIPlajpC5gLaKgwTdiA9kaAATW8Bw
YdJqMHLaWHxV/uzQYG7HPqkOdy/xDv0VBQudvc9BAoGBANIzo192Da7c7BrtDetc
C+/sc8B4Y/1mkTDc/LS3b53VfGA+otsCAKZF53ezK8xQrtWONFKF9dWkk/fPERsV
qgfg3WQIiaobDpLbjgbIOkcP/PX2H0HqhrRofDBt6QiTANBuh6FK4KOPnklXuYPp
8Sj4YnidN9RxTxPdIQuoQKaV
-----END PRIVATE KEY-----

View File

Before

Width:  |  Height:  |  Size: 229 KiB

After

Width:  |  Height:  |  Size: 229 KiB

1
static/js/qrcode.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -73,6 +73,41 @@
font-size: 1.2rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
padding-bottom: 10px;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
user-select: none;
}
h2::after {
content: '▼';
font-size: 0.8em;
transition: transform 0.3s;
opacity: 0.7;
}
.card.collapsed h2::after {
transform: rotate(-90deg);
}
.card.collapsed h2 {
border-bottom: none;
margin-bottom: 0;
}
.card-content {
transition: all 0.3s ease-in-out;
max-height: 3000px; /* Ensure enough height for content */
opacity: 1;
overflow: hidden;
}
.card.collapsed .card-content {
max-height: 0;
opacity: 0;
margin: 0;
padding: 0;
}
.form-group {
@@ -287,8 +322,17 @@
<div class="container">
<h1>管理后台</h1>
<div style="text-align: center; margin-bottom: 25px;">
<a href="/wall" target="_blank" style="text-decoration: none;">
<button class="btn-lg" style="background: linear-gradient(90deg, #6a11cb 0%, #2575fc 100%); width: auto; padding: 15px 40px; box-shadow: 0 0 20px rgba(37, 117, 252, 0.5);">
📺 打开签到大屏 (Large Screen)
</button>
</a>
</div>
<div class="card">
<h2>基本信息设置</h2>
<div class="card-content">
<div class="form-group">
<label>活动标题</label>
<input type="text" id="event_title">
@@ -310,20 +354,28 @@
<textarea id="event_content" rows="4"></textarea>
</div>
</div>
</div>
<div class="card">
<div class="card collapsed">
<h2>签到设置</h2>
<div class="card-content">
<div class="checkbox-wrapper">
<input type="checkbox" id="enable_ticket_validation">
<label for="enable_ticket_validation">开启验票验证 (核对数据库与付款记录)</label>
</div>
<div class="checkbox-wrapper">
<input type="checkbox" id="enable_sms_verification">
<label for="enable_sms_verification">开启手机号短信验证 (SMS Verification)</label>
</div>
<p style="font-size: 0.9em; color: var(--text-muted); margin-left: 30px;">
关闭后将跳过数据库核查,直接允许签到(若用户不存在将自动创建)。
</p>
</div>
</div>
<div class="card">
<div class="card collapsed">
<h2>座位设置</h2>
<div class="card-content">
<div class="checkbox-wrapper">
<input type="checkbox" id="enable_seating" onchange="toggleSeatingInputs()">
<label for="enable_seating">开启分桌功能</label>
@@ -339,9 +391,12 @@
</div>
</div>
</div>
</div>
<div class="card">
<h2>表单字段设置</h2>
<div class="card collapsed">
<h2>报名输入字段设置 (Registration Fields)</h2>
<div class="card-content">
<p style="font-size: 0.9em; color: var(--text-muted); margin-bottom: 15px;">对应 gsdh_data 表 (ticket.html)</p>
<table style="width: 100%; border-collapse: collapse; color: white;">
<thead>
<tr style="border-bottom: 1px solid rgba(255,255,255,0.1); text-align: left;">
@@ -350,14 +405,82 @@
<th style="padding: 10px;">是否必填</th>
</tr>
</thead>
<tbody id="field_config_body">
<!-- Fields will be injected here -->
<tbody id="ticket_field_config_body">
<!-- Ticket Fields will be injected here -->
</tbody>
</table>
</div>
</div>
<div class="card">
<div class="card collapsed">
<h2>签到/大屏字段设置 (Check-in Fields)</h2>
<div class="card-content">
<p style="font-size: 0.9em; color: var(--text-muted); margin-bottom: 15px;">对应 checkin_info 表 (wall/checkin)</p>
<table style="width: 100%; border-collapse: collapse; color: white;">
<thead>
<tr style="border-bottom: 1px solid rgba(255,255,255,0.1); text-align: left;">
<th style="padding: 10px;">字段名称</th>
<th style="padding: 10px;">是否显示</th>
<th style="padding: 10px;">是否必填</th>
</tr>
</thead>
<tbody id="checkin_field_config_body">
<!-- Checkin Fields will be injected here -->
</tbody>
</table>
</div>
</div>
<div class="card collapsed">
<h2>支付设置 (Payment)</h2>
<div class="card-content">
<div class="checkbox-wrapper">
<input type="checkbox" id="enable_payment">
<label for="enable_payment">开启付费报名 (Enable Paid Registration)</label>
</div>
<p style="font-size: 0.9em; color: var(--text-muted); margin-left: 30px; margin-bottom: 15px;">
开启后,<a href="/ticket" target="_blank" style="color: var(--primary-color);">/ticket</a> 页面将开放访问。
</p>
<div class="form-group">
<label>支付金额 (元)</label>
<input type="number" id="payment_amount" step="0.01" min="0.01">
</div>
<h3 style="color: #b0c4de; margin: 20px 0 15px; border-bottom: 1px solid rgba(255,255,255,0.1); padding-bottom: 5px; font-size: 1rem;">微信支付配置 (WeChat Pay V3)</h3>
<div class="form-group">
<label>AppID</label>
<input type="text" id="wx_appid" placeholder="wx...">
</div>
<div class="form-group">
<label>MchID (商户号)</label>
<input type="text" id="wx_mchid" placeholder="16...">
</div>
<div class="form-group">
<label>API V3 Key</label>
<div class="input-group">
<input type="password" id="wx_api_v3_key" placeholder="32位密钥">
<button class="toggle-btn" aria-label="显示/隐藏" data-target="wx_api_v3_key" onclick="togglePasswordVisibility(this)">显示</button>
</div>
</div>
<div class="form-group">
<label>Serial No (证书序列号)</label>
<input type="text" id="wx_serial_no" placeholder="证书序列号">
</div>
<div class="form-group">
<label>商户私钥路径 (Private Key Path)</label>
<input type="text" id="wx_private_key_path" placeholder="cert/apiclient_key.pem">
</div>
<div class="form-group">
<label>回调地址 (Notify URL)</label>
<input type="text" id="wx_notify_url" placeholder="https://your-domain.com/api/payment/notify">
</div>
</div>
</div>
<div class="card collapsed">
<h2>数据库设置</h2>
<div class="card-content">
<div class="form-group">
<label>数据库地址 (Host)</label>
<input type="text" id="db_host" placeholder="localhost">
@@ -382,10 +505,51 @@
<input type="text" id="db_name">
</div>
<button class="btn-success" onclick="testDbConnection()">测试连接</button>
<button class="btn-lg" style="margin-left: 10px; background: linear-gradient(90deg, #17a2b8 0%, #138496 100%);" onclick="initDatabase()">初始化/迁移结构</button>
<p style="font-size: 0.9em; color: var(--text-muted); margin-top: 10px;">
提示:切换到新数据库后,点击“初始化/迁移结构”可自动创建表结构(不会删除现有数据)。
</p>
</div>
</div>
<div class="card">
<div class="card collapsed">
<h2>大屏设置 (Wall Config)</h2>
<div class="card-content">
<div class="form-group">
<label>背景图片透明度 (0-1)</label>
<input type="number" id="wall_bg_opacity" step="0.1" min="0" max="1" value="0.3">
</div>
<div class="checkbox-wrapper">
<input type="checkbox" id="wall_show_title"> <label for="wall_show_title">显示大屏标题</label>
</div>
<div class="form-group">
<label>了解我们 URL (手机端底部按钮)</label>
<input type="text" id="wall_learn_more_url" placeholder="https://...">
</div>
<div class="form-group">
<label>显示字段</label>
<div class="checkbox-wrapper">
<input type="checkbox" id="wall_show_name"> <label for="wall_show_name">姓名</label>
</div>
<div class="checkbox-wrapper">
<input type="checkbox" id="wall_show_company"> <label for="wall_show_company">单位名称</label>
</div>
<div class="checkbox-wrapper">
<input type="checkbox" id="wall_show_position"> <label for="wall_show_position">职务</label>
</div>
<div class="checkbox-wrapper">
<input type="checkbox" id="wall_show_vision"> <label for="wall_show_vision">愿景 (Vision)</label>
</div>
<div class="checkbox-wrapper">
<input type="checkbox" id="wall_show_scope"> <label for="wall_show_scope">业务范围 (弹幕)</label>
</div>
</div>
</div>
</div>
<div class="card collapsed">
<h2>主题设置</h2>
<div class="card-content">
<div class="form-group">
<label>主色调 (Primary Color)</label>
<input type="color" id="primary_color">
@@ -399,9 +563,11 @@
<input type="color" id="bg_color">
</div>
</div>
</div>
<div class="card">
<div class="card collapsed">
<h2>图片设置</h2>
<div class="card-content">
<div class="form-group">
<label>顶部头图</label>
<input type="file" id="header_image_file" accept="image/*" style="padding: 10px 0;">
@@ -409,24 +575,36 @@
</div>
<button onclick="uploadImage()">上传图片</button>
</div>
</div>
<div style="margin-bottom: 25px; text-align: center;">
<button onclick="saveConfig()" class="btn-block btn-lg">保存所有设置</button>
</div>
<div class="card danger-zone">
<div class="card danger-zone collapsed">
<h2>危险区域</h2>
<div class="card-content">
<p style="color: #ffcccc; margin-bottom: 15px;">此处操作不可逆,请谨慎操作。</p>
<div class="form-group">
<button class="btn-danger" onclick="resetDatabase()">重置/创建数据库</button>
<p style="font-size: 0.9em; color: #ffcccc; margin-top: 10px;">这将清空所有签到数据并重新创建表结构。</p>
</div>
</div>
</div>
<div id="message"></div>
</div>
<script>
// Collapsible Cards Logic
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('.card h2').forEach(header => {
header.addEventListener('click', () => {
header.parentElement.classList.toggle('collapsed');
});
});
});
// Use secret from backend
const ACCESS_SECRET = "{{ secret }}";
@@ -493,6 +671,19 @@
// Checkin Config
document.getElementById('enable_ticket_validation').checked = config.enable_ticket_validation !== false;
document.getElementById('enable_sms_verification').checked = config.enable_sms_verification === true;
// Payment Config
document.getElementById('enable_payment').checked = config.enable_payment === true;
document.getElementById('payment_amount').value = config.payment_amount || 0.01;
const wx = config.wechat_pay_config || {};
document.getElementById('wx_appid').value = wx.appid || '';
document.getElementById('wx_mchid').value = wx.mchid || '';
document.getElementById('wx_api_v3_key').value = wx.api_v3_key || '';
document.getElementById('wx_serial_no').value = wx.serial_no || '';
document.getElementById('wx_private_key_path').value = wx.private_key_path || '';
document.getElementById('wx_notify_url').value = wx.notify_url || '';
// Seating Config
document.getElementById('enable_seating').checked = config.enable_seating !== false; // Default true
@@ -500,15 +691,21 @@
document.getElementById('max_per_table').value = config.max_per_table || 10;
toggleSeatingInputs();
// Field Config
renderFieldConfig(config.field_config || {
// Field Configs
renderFieldConfig('ticket_field_config_body', config.ticket_field_config || {
"name": {"label": "姓名", "show": true, "required": true},
"phone": {"label": "手机号码", "show": true, "required": true},
"industry_company": {"label": "行业名称/单位名称", "show": true, "required": true}
}, ["name", "phone", "industry_company"]);
renderFieldConfig('checkin_field_config_body', config.checkin_field_config || {
"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}
});
}, ["name", "phone", "company_name", "position", "business_scope", "vision_2026"]);
// DB Config
document.getElementById('db_host').value = config.db_host || '';
@@ -516,14 +713,27 @@
document.getElementById('db_user').value = config.db_user || '';
document.getElementById('db_password').value = config.db_password || '';
document.getElementById('db_name').value = config.db_name || '';
// Wall Config
const wc = config.wall_config || {};
const show = wc.show_fields || {};
document.getElementById('wall_bg_opacity').value = wc.bg_opacity !== undefined ? wc.bg_opacity : 0.3;
document.getElementById('wall_show_title').checked = wc.show_title !== false;
document.getElementById('wall_learn_more_url').value = wc.learn_more_url || '';
document.getElementById('wall_show_name').checked = show.name !== false;
document.getElementById('wall_show_position').checked = show.position !== false;
document.getElementById('wall_show_vision').checked = show.vision_2026 !== false;
document.getElementById('wall_show_scope').checked = show.business_scope !== false;
});
function renderFieldConfig(fieldConfig) {
const tbody = document.getElementById('field_config_body');
function renderFieldConfig(tbodyId, fieldConfig, orderedKeys) {
const tbody = document.getElementById(tbodyId);
tbody.innerHTML = '';
// Order of fields
const orderedKeys = ["name", "phone", "company_name", "position", "business_scope", "vision_2026"];
// If orderedKeys not provided, use default order or Object.keys
if (!orderedKeys) {
orderedKeys = Object.keys(fieldConfig);
}
orderedKeys.forEach(key => {
if (!fieldConfig[key]) return;
@@ -531,9 +741,7 @@
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.
// Name/Phone lock logic
const isLocked = (key === 'name' || key === 'phone');
tr.innerHTML = `
@@ -549,16 +757,14 @@
});
}
function getFieldConfig() {
function getFieldConfig(tbodyId) {
const config = {};
const rows = document.querySelectorAll('#field_config_body tr');
const rows = document.querySelectorAll(`#${tbodyId} tr`);
rows.forEach(row => {
const showCb = row.querySelector('.field-show');
const reqCb = row.querySelector('.field-required');
const key = showCb.dataset.key;
// 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] = {
@@ -567,7 +773,7 @@
required: reqCb.checked
};
});
// Ensure locked fields are true/true if disabled
// Ensure locked fields are true/true
if (config.name) { config.name.show = true; config.name.required = true; }
if (config.phone) { config.phone.show = true; config.phone.required = true; }
@@ -596,6 +802,19 @@
// Checkin Config
enable_ticket_validation: document.getElementById('enable_ticket_validation').checked,
enable_sms_verification: document.getElementById('enable_sms_verification').checked,
// Payment Config
enable_payment: document.getElementById('enable_payment').checked,
payment_amount: parseFloat(document.getElementById('payment_amount').value),
wechat_pay_config: {
appid: document.getElementById('wx_appid').value,
mchid: document.getElementById('wx_mchid').value,
api_v3_key: document.getElementById('wx_api_v3_key').value,
serial_no: document.getElementById('wx_serial_no').value,
private_key_path: document.getElementById('wx_private_key_path').value,
notify_url: document.getElementById('wx_notify_url').value
},
// Seating Config
enable_seating: document.getElementById('enable_seating').checked,
@@ -603,7 +822,22 @@
max_per_table: parseInt(document.getElementById('max_per_table').value),
// Field Config
field_config: getFieldConfig(),
ticket_field_config: getFieldConfig('ticket_field_config_body'),
checkin_field_config: getFieldConfig('checkin_field_config_body'),
// Wall Config
wall_config: {
bg_opacity: parseFloat(document.getElementById('wall_bg_opacity').value),
show_title: document.getElementById('wall_show_title').checked,
learn_more_url: document.getElementById('wall_learn_more_url').value,
show_fields: {
name: document.getElementById('wall_show_name').checked,
company_name: document.getElementById('wall_show_company').checked,
position: document.getElementById('wall_show_position').checked,
vision_2026: document.getElementById('wall_show_vision').checked,
business_scope: document.getElementById('wall_show_scope').checked
}
},
// DB Config
db_host: document.getElementById('db_host').value,
@@ -682,6 +916,24 @@
}
}
async function initDatabase() {
if (!confirm('确定要在当前配置的数据库中初始化/迁移表结构吗?\n此操作安全不会删除现有数据')) return;
try {
const res = await fetch('/api/admin/init-db', {
method: 'POST'
});
const data = await res.json();
if (res.ok) {
showMessage('数据库结构初始化/迁移成功', 'success');
} else {
showMessage('初始化失败: ' + data.message, 'error');
}
} catch (e) {
showMessage('网络错误', 'error');
}
}
async function resetDatabase() {
if (!confirm('确定要重置数据库吗?所有数据将被清空!')) return;

View File

@@ -487,9 +487,20 @@
<label>请输入手机号码或姓名</label>
<input type="text" id="search-input" placeholder="例如13800000000 或 张三">
</div>
<button onclick="searchUser()" id="search-btn">查询</button>
<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>
{% if config.enable_payment %}
<div style="margin-top: 25px; padding-top: 20px; border-top: 1px solid rgba(255,255,255,0.1); text-align: center;">
<p style="color: var(--text-muted); font-size: 0.9rem; margin-bottom: 15px;">尚未购票?</p>
<a href="/ticket" style="text-decoration: none;">
<button style="background: transparent; border: 1px solid var(--primary-color); color: var(--primary-color);">
购票报名 (¥{{ config.payment_amount }})
</button>
</a>
</div>
{% endif %}
</div>
<!-- Step 2: Fill Info -->
@@ -513,35 +524,33 @@
<input type="text" id="form-phone" required>
</div>
{% set fc = config.field_config or {} %}
{% if fc.company_name and fc.company_name.show %}
<div class="input-group">
<label>{{ fc.company_name.label }}</label>
<input type="text" id="form-company" placeholder="点此输入" {{ 'required' if fc.company_name.required else '' }}>
<div class="input-group hidden" id="sms-verification-group">
<label>验证码</label>
<div style="display: flex; gap: 10px;">
<input type="text" id="form-verification-code" placeholder="输入验证码" style="flex: 1;">
<button type="button" id="send-checkin-code-btn" onclick="sendCheckinSms()" style="width: auto; padding: 14px; background: var(--secondary-color); margin-bottom: 0; border: none; border-radius: 8px; color: white; cursor: pointer; font-weight: bold;">发送验证码</button>
</div>
{% endif %}
{% if fc.position and fc.position.show %}
<div class="input-group">
<label>{{ fc.position.label }}</label>
<input type="text" id="form-position" placeholder="点此输入" {{ 'required' if fc.position.required else '' }}>
</div>
{% endif %}
{% if fc.business_scope and fc.business_scope.show %}
<div class="input-group">
<label>{{ fc.business_scope.label }}</label>
<textarea id="form-business" rows="2" placeholder="点此输入" {{ 'required' if fc.business_scope.required else '' }}></textarea>
<div class="input-group hidden" id="group-company">
<label id="label-company">单位名称</label>
<input type="text" id="form-company" placeholder="点此输入">
</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 class="input-group hidden" id="group-position">
<label id="label-position">职务</label>
<input type="text" id="form-position" placeholder="点此输入">
</div>
<div class="input-group hidden" id="group-business">
<label id="label-business">公司主要经营 / 业务</label>
<textarea id="form-business" rows="2" placeholder="点此输入"></textarea>
</div>
<div class="input-group hidden" id="group-vision">
<label id="label-vision">2026年业务愿景</label>
<textarea id="form-vision" rows="3" placeholder="点此输入 (为了业务更好对接,最好表述清晰)"></textarea>
</div>
{% endif %}
<input type="hidden" id="form-location" value="">
@@ -568,7 +577,7 @@
</div>
<p style="margin-top: 20px;">请入座等待会议开始</p>
<button onclick="resetFlow()" style="margin-top: 30px; background: transparent; border: 1px solid rgba(255,255,255,0.2);">返回首页</button>
<button id="success-btn" onclick="window.location.href='/wall'" style="margin-top: 30px; background: transparent; border: 1px solid rgba(255,255,255,0.2);">前往活动</button>
</div>
<!-- Step 4: Already Signed -->
@@ -593,7 +602,7 @@
</div>
<p style="margin-top: 20px;">请入座等待会议开始</p>
<button onclick="resetFlow()" style="margin-top: 30px; background: transparent; border: 1px solid rgba(255,255,255,0.2);">返回首页</button>
<button id="signed-btn" onclick="window.location.href='/wall'" style="margin-top: 30px; background: transparent; border: 1px solid rgba(255,255,255,0.2);">前往活动</button>
</div>
</div>
@@ -635,6 +644,140 @@
// Inject Config
const CONFIG = {{ config | tojson }};
let isCheckinCodeVerified = false;
async function refreshConfig() {
try {
const res = await fetch('/api/admin/config');
if (res.ok) {
const newConfig = await res.json();
// Update global CONFIG object
Object.assign(CONFIG, newConfig);
applyConfigUI();
}
} catch (e) {
console.error("Failed to refresh config", e);
}
}
function applyConfigUI() {
const smsGroup = document.getElementById('sms-verification-group');
if (smsGroup) {
if (CONFIG.enable_sms_verification) {
smsGroup.classList.remove('hidden');
} else {
smsGroup.classList.add('hidden');
}
}
// Apply field config (Show/Hide/Required)
const fc = CONFIG.checkin_field_config || {};
const fields = [
{ key: 'company_name', groupId: 'group-company', inputId: 'form-company', labelId: 'label-company' },
{ key: 'position', groupId: 'group-position', inputId: 'form-position', labelId: 'label-position' },
{ key: 'business_scope', groupId: 'group-business', inputId: 'form-business', labelId: 'label-business' },
{ key: 'vision_2026', groupId: 'group-vision', inputId: 'form-vision', labelId: 'label-vision' }
];
fields.forEach(field => {
const conf = fc[field.key] || { show: false, required: false, label: '' };
const group = document.getElementById(field.groupId);
const input = document.getElementById(field.inputId);
const label = document.getElementById(field.labelId);
if (group && input) {
// Visibility
if (conf.show) {
group.classList.remove('hidden');
} else {
group.classList.add('hidden');
}
// Required
input.required = conf.required === true;
// Label
if (label && conf.label) {
label.textContent = conf.label;
}
}
});
}
function updateCheckinButtonState() {
const btn = document.getElementById('submit-btn');
const codeInput = document.getElementById('form-verification-code');
const smsGroup = document.getElementById('sms-verification-group');
// Check if loading (by checking loader class or text content)
if (btn.innerHTML.includes('loader')) return;
// Check if SMS verification is enabled (group is visible)
if (smsGroup && !smsGroup.classList.contains('hidden')) {
if (isCheckinCodeVerified && codeInput && codeInput.value.trim().length === 4) {
btn.disabled = false;
} else {
btn.disabled = true;
}
} else {
btn.disabled = false;
}
}
async function verifyCheckinCode() {
const codeInput = document.getElementById('form-verification-code');
const phoneInput = document.getElementById('form-phone');
const errorDiv = document.getElementById('submit-error');
if (!codeInput) return;
const code = codeInput.value.trim();
const phone = phoneInput ? phoneInput.value.trim() : '';
if (code.length === 4 && phone) {
try {
const res = await fetch('/api/verify-sms-code', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({phone: phone, code: code})
});
const data = await res.json();
if (data.success) {
isCheckinCodeVerified = true;
errorDiv.classList.add('hidden');
} else {
isCheckinCodeVerified = false;
errorDiv.textContent = "验证码错误";
errorDiv.classList.remove('hidden');
}
} catch(e) {
isCheckinCodeVerified = false;
}
} else {
isCheckinCodeVerified = false;
if (code.length < 4) errorDiv.classList.add('hidden');
}
updateCheckinButtonState();
}
window.addEventListener('DOMContentLoaded', () => {
applyConfigUI();
const codeInput = document.getElementById('form-verification-code');
const phoneInput = document.getElementById('form-phone');
if (codeInput) {
codeInput.addEventListener('input', verifyCheckinCode);
// Initial check
updateCheckinButtonState();
}
if (phoneInput) {
phoneInput.addEventListener('input', () => {
isCheckinCodeVerified = false;
updateCheckinButtonState();
});
}
});
async function searchUser() {
const query = document.getElementById('search-input').value.trim();
const btn = document.getElementById('search-btn');
@@ -661,6 +804,7 @@
if (response.ok) {
if (data.allow_create) {
await refreshConfig();
const u = data.user || {};
const tempId = 'TEMP_' + Date.now();
currentUser = { new_id: tempId };
@@ -675,6 +819,8 @@
document.getElementById('step-search').classList.add('hidden');
document.getElementById('step-form').classList.remove('hidden');
applyConfigUI();
updateCheckinButtonState();
return;
}
@@ -716,6 +862,7 @@
}
} else {
// Single user found
await refreshConfig();
selectUser(data.user);
}
} else {
@@ -732,11 +879,15 @@
}
}
function selectUser(user) {
async function selectUser(user) {
// Check if fee is 0 (unpaid)
if (user.fee == 0) {
// Ensure fee is treated as number. If fee is missing or 0, block checkin.
// CONFIG.payment_amount is usually 0.01 or 26.
const fee = parseFloat(user.fee || 0);
if (fee <= 0) {
const errorDiv = document.getElementById('search-error');
errorDiv.textContent = "⚠️ 无法签到请先前往签到处付费26元";
const amount = CONFIG.payment_amount || 26;
errorDiv.textContent = `⚠️ 无法签到:请先前往签到处付费${amount}`;
errorDiv.classList.remove('hidden');
errorDiv.classList.add('important-warning');
errorDiv.scrollIntoView({ behavior: 'smooth', block: 'center' });
@@ -747,18 +898,32 @@
// Populate form
document.getElementById('gsdh-id').value = user.new_id;
document.getElementById('form-name').value = user.name;
document.getElementById('form-phone').value = user.phone;
document.getElementById('form-company').value = user.industry_company || ''; // Pre-fill if we have something roughly mapping
const setVal = (id, val) => {
const el = document.getElementById(id);
if (el) el.value = val || '';
};
setVal('form-name', user.name);
setVal('form-phone', user.phone);
setVal('form-company', user.industry_company);
// Clear other optional fields to avoid carrying over data if user switches
setVal('form-position', user.position); // Note: user.position might not exist in search result
setVal('form-business', user.business_scope);
setVal('form-vision', user.vision_2026);
// Display preview
document.getElementById('display-name').textContent = user.name;
document.getElementById('display-phone').textContent = user.phone;
document.getElementById('display-company').textContent = user.industry_company || '暂无单位信息';
if (document.getElementById('display-name')) document.getElementById('display-name').textContent = user.name;
if (document.getElementById('display-phone')) document.getElementById('display-phone').textContent = user.phone;
if (document.getElementById('display-company')) document.getElementById('display-company').textContent = user.industry_company || '暂无单位信息';
// Ensure UI reflects current config (e.g. SMS button)
applyConfigUI();
// Switch steps
document.getElementById('step-search').classList.add('hidden');
document.getElementById('step-form').classList.remove('hidden');
updateCheckinButtonState();
}
async function submitCheckin(e) {
@@ -780,6 +945,7 @@
gsdh_id: getVal('gsdh-id'),
name: getVal('form-name'),
phone: getVal('form-phone'),
verification_code: getVal('form-verification-code'),
company_name: getVal('form-company'),
position: getVal('form-position'),
business_scope: getVal('form-business'),
@@ -799,6 +965,7 @@
const data = await response.json();
if (response.ok) {
document.getElementById('success-btn').textContent = payload.name + '/点击进入活动大厅';
if (CONFIG.enable_seating !== false) {
// Show assigned seat
document.getElementById('seat-display').textContent = data.seat || "自由席";
@@ -828,8 +995,8 @@
errorDiv.textContent = "网络错误,请稍后重试";
errorDiv.classList.remove('hidden');
} finally {
btn.disabled = false;
btn.textContent = '确认签到';
updateCheckinButtonState();
}
}
@@ -891,6 +1058,62 @@
document.getElementById('user-list').innerHTML = '';
document.getElementById('checkin-form').reset();
currentUser = null;
updateCheckinButtonState();
}
async function sendCheckinSms() {
const phone = document.getElementById('form-phone').value.trim();
const errorDiv = document.getElementById('submit-error');
if (!phone || !/^1[3-9]\d{9}$/.test(phone)) {
errorDiv.textContent = '请输入有效的手机号码';
errorDiv.classList.remove('hidden');
return;
} else {
errorDiv.classList.add('hidden');
}
const btn = document.getElementById('send-checkin-code-btn');
if (!btn) return;
btn.disabled = true;
let count = 60;
const originalText = "发送验证码";
// Show loading state
btn.innerHTML = '<span class="loader" style="width: 14px; height: 14px; border-width: 2px; margin-right: 5px;"></span> 发送中...';
try {
const res = await fetch('/api/send-sms', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({phone: phone})
});
const data = await res.json();
if (data.success) {
btn.innerText = `${count}s`;
const timer = setInterval(() => {
count--;
btn.innerText = `${count}s`;
if (count <= 0) {
clearInterval(timer);
btn.disabled = false;
btn.innerText = originalText;
}
}, 1000);
} else {
errorDiv.textContent = data.message || '发送失败';
errorDiv.classList.remove('hidden');
btn.disabled = false;
btn.innerText = originalText;
}
} catch (e) {
errorDiv.textContent = '网络错误';
errorDiv.classList.remove('hidden');
btn.disabled = false;
btn.innerText = originalText;
}
}
</script>
</body>

104
templates/success.html Normal file
View File

@@ -0,0 +1,104 @@
<!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.7);
--text-color: #ffffff;
}
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;
justify-content: center;
align-items: center;
padding: 20px;
margin: 0;
}
.card {
background: var(--card-bg);
border-radius: 20px;
padding: 40px;
text-align: center;
max-width: 500px;
width: 100%;
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(0, 242, 255, 0.3);
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
animation: fadeIn 0.8s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.icon-success {
width: 80px;
height: 80px;
border-radius: 50%;
background: #28a745;
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 40px;
margin: 0 auto 20px;
box-shadow: 0 0 20px rgba(40, 167, 69, 0.4);
}
h1 {
font-size: 1.8rem;
margin-bottom: 15px;
color: #fff;
}
p {
color: #ccc;
line-height: 1.6;
margin-bottom: 30px;
}
.btn {
display: inline-block;
padding: 12px 30px;
background: linear-gradient(90deg, #0051ff 0%, #00f2ff 100%);
color: white;
text-decoration: none;
border-radius: 25px;
font-weight: bold;
transition: all 0.3s;
box-shadow: 0 4px 15px rgba(0, 242, 255, 0.3);
}
.btn:hover {
transform: translateY(-2px);
box-shadow: 0 6px 25px rgba(0, 242, 255, 0.5);
}
</style>
</head>
<body>
<div class="card">
<div class="icon-success"></div>
<h1>报名成功</h1>
<p>您已成功支付并完成报名!<br>请届时凭手机号或二维码入场。</p>
<a href="/" class="btn">返回首页</a>
</div>
</body>
</html>

573
templates/ticket.html Normal file
View File

@@ -0,0 +1,573 @@
<!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>
<script src="/static/js/qrcode.min.js"></script>
<style>
:root {
--primary-color: {{ config.primary_color }};
--secondary-color: {{ config.secondary_color }};
--bg-color: {{ config.bg_color }};
--card-bg: rgba(12, 24, 50, 0.7);
--text-color: #ffffff;
--input-bg: rgba(0, 0, 0, 0.4);
--border-color: rgba(0, 242, 255, 0.3);
}
* {
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;
padding: 20px;
}
.container {
width: 100%;
max-width: 600px;
margin-top: 40px;
margin-bottom: 60px;
animation: fadeIn 0.8s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
header {
text-align: center;
margin-bottom: 40px;
}
h1 {
font-size: 2rem;
margin-bottom: 10px;
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);
}
.subtitle {
font-size: 1rem;
color: var(--primary-color);
letter-spacing: 2px;
opacity: 0.9;
}
.card {
background: var(--card-bg);
border-radius: 20px;
padding: 30px;
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid var(--border-color);
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
}
.form-group {
margin-bottom: 25px;
}
label {
display: block;
margin-bottom: 10px;
color: var(--primary-color);
font-size: 0.95rem;
font-weight: bold;
}
input[type="text"], input[type="tel"] {
width: 100%;
padding: 15px;
background: var(--input-bg);
border: 1px solid var(--border-color);
border-radius: 12px;
color: white;
font-size: 1.1rem;
outline: none;
transition: all 0.3s;
}
input:focus {
border-color: var(--primary-color);
box-shadow: 0 0 15px rgba(0, 242, 255, 0.2);
background: rgba(0, 0, 0, 0.6);
}
.submit-btn {
width: 100%;
padding: 18px;
background: linear-gradient(90deg, #0051ff 0%, #00f2ff 100%);
color: white;
border: none;
border-radius: 12px;
font-size: 1.2rem;
font-weight: bold;
cursor: pointer;
transition: all 0.3s;
box-shadow: 0 4px 15px rgba(0, 242, 255, 0.3);
margin-top: 20px;
position: relative;
overflow: hidden;
}
.submit-btn:hover {
transform: translateY(-2px);
box-shadow: 0 6px 25px rgba(0, 242, 255, 0.5);
}
.submit-btn:disabled {
background: #444;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.price-tag {
text-align: center;
margin-bottom: 20px;
font-size: 1.2rem;
color: #fff;
}
.price-amount {
font-size: 2rem;
color: #ffaa00;
font-weight: bold;
margin: 0 5px;
}
#message {
margin-top: 20px;
padding: 15px;
border-radius: 12px;
text-align: center;
display: none;
font-weight: bold;
}
.success {
background: rgba(40, 167, 69, 0.2);
border: 1px solid #28a745;
color: #28a745;
}
.error {
background: rgba(220, 53, 69, 0.2);
border: 1px solid #dc3545;
color: #ff6b6b;
}
.loading {
display: inline-block;
width: 20px;
height: 20px;
border: 3px solid rgba(255,255,255,.3);
border-radius: 50%;
border-top-color: #fff;
animation: spin 1s ease-in-out infinite;
margin-right: 10px;
vertical-align: middle;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Modal Styles */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.8);
display: none;
justify-content: center;
align-items: center;
z-index: 1000;
backdrop-filter: blur(5px);
}
.modal-content {
background: #fff;
padding: 30px;
border-radius: 15px;
text-align: center;
max-width: 90%;
width: 400px;
position: relative;
box-shadow: 0 0 30px rgba(0, 242, 255, 0.2);
}
.modal-close {
position: absolute;
top: 10px;
right: 15px;
font-size: 24px;
cursor: pointer;
color: #333;
}
#qrcode {
margin: 20px auto;
display: flex;
justify-content: center;
}
.modal-title {
color: #333;
margin-bottom: 10px;
font-size: 1.2rem;
font-weight: bold;
}
.modal-tip {
color: #666;
font-size: 0.9rem;
margin-bottom: 20px;
}
</style>
</head>
<body>
<div class="container">
<header>
<h1>{{ config.event_title }}</h1>
<div class="subtitle">在线报名通道</div>
</header>
<div class="card">
<div class="price-tag">
报名费用 <span class="price-amount">¥{{ config.payment_amount }}</span>
</div>
<form id="paymentForm" onsubmit="handlePayment(event)">
{% set ordered_keys = ['name', 'phone', 'industry_company'] %}
{% for key in ordered_keys %}
{% set field = config.ticket_field_config.get(key) %}
{% if field and field.show %}
<div class="form-group">
<label>{{ field.label }}{% if field.required %} *{% endif %}</label>
{% if key == 'phone' %}
<input type="tel" id="{{ key }}" {% if field.required %}required{% endif %} placeholder="请输入{{ field.label }}" pattern="[0-9]{11}">
{% if config.enable_sms_verification %}
<div style="display: flex; gap: 10px; margin-top: 10px;">
<input type="text" id="verification_code" placeholder="请先发送验证码" style="flex: 1;" disabled>
<button type="button" id="send-code-btn" onclick="sendSmsCode()" style="width: auto; padding: 10px; font-size: 0.9rem; background: var(--secondary-color); margin-top: 0; border: none; border-radius: 8px; color: white; cursor: pointer; display: flex; align-items: center; justify-content: center; min-width: 100px;">发送验证码</button>
</div>
{% endif %}
{% else %}
<input type="text" id="{{ key }}" {% if field.required %}required{% endif %} placeholder="请输入{{ field.label }}">
{% endif %}
</div>
{% endif %}
{% endfor %}
<button type="submit" class="submit-btn" id="submitBtn">
立即支付报名
</button>
</form>
<div id="message"></div>
</div>
</div>
<!-- QR Code Modal -->
<div id="qrModal" class="modal-overlay">
<div class="modal-content">
<span class="modal-close" onclick="closeModal()">&times;</span>
<div class="modal-title">微信扫码支付</div>
<div style="margin: 15px 0; color: #28a745; font-weight: bold; font-size: 1.1rem;">
<span style="display:inline-block; margin-right:5px; vertical-align:middle;">📱</span>
请使用微信“扫一扫”
</div>
<div id="qrcode"></div>
<div id="payment-status" style="color: #666; font-size: 14px; margin-top: 15px;">等待支付...</div>
</div>
</div>
<script>
let pollTimer = null;
let isCodeVerified = false;
function updateSubmitButtonState() {
const btn = document.getElementById('submitBtn');
const codeInput = document.getElementById('verification_code');
// Only update if not in loading state (checking innerHTML for loading spinner)
if (btn.innerHTML.includes('loading')) return;
if (codeInput) {
if (isCodeVerified && codeInput.value.trim().length === 4) {
btn.disabled = false;
} else {
btn.disabled = true;
}
} else {
btn.disabled = false;
}
}
async function verifyCode() {
const codeInput = document.getElementById('verification_code');
const phoneInput = document.getElementById('phone');
if (!codeInput) return;
const code = codeInput.value.trim();
const phone = phoneInput ? phoneInput.value.trim() : '';
if (code.length === 4 && phone) {
try {
const res = await fetch('/api/verify-sms-code', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({phone: phone, code: code})
});
const data = await res.json();
if (data.success) {
isCodeVerified = true;
showMessage('验证码正确', 'success');
} else {
isCodeVerified = false;
showMessage('验证码错误', 'error');
}
} catch(e) {
isCodeVerified = false;
}
} else {
isCodeVerified = false;
}
updateSubmitButtonState();
}
function closeModal() {
document.getElementById('qrModal').style.display = 'none';
if (pollTimer) clearInterval(pollTimer);
document.getElementById('qrcode').innerHTML = '';
// Reset button state
const btn = document.getElementById('submitBtn');
btn.innerHTML = '立即支付报名';
updateSubmitButtonState();
}
// Initialize button state on load
window.addEventListener('DOMContentLoaded', () => {
const codeInput = document.getElementById('verification_code');
const phoneInput = document.getElementById('phone');
if (codeInput) {
updateSubmitButtonState();
codeInput.addEventListener('input', verifyCode);
}
if (phoneInput) {
phoneInput.addEventListener('input', () => {
isCodeVerified = false;
updateSubmitButtonState();
});
}
});
async function handlePayment(e) {
e.preventDefault();
const btn = document.getElementById('submitBtn');
const msg = document.getElementById('message');
const originalText = "立即支付报名"; // Hardcoded or get from element
// Validate
const getVal = (id) => {
const el = document.getElementById(id);
return el ? el.value.trim() : '';
};
const name = getVal('name');
const phone = getVal('phone');
if (!name || !phone) {
showMessage('请填写必填项', 'error');
return;
}
// Loading State
btn.disabled = true;
btn.innerHTML = '<span class="loading"></span> 处理中...';
msg.style.display = 'none';
const data = {
name: name,
phone: phone,
verification_code: getVal('verification_code'),
company_name: getVal('industry_company')
};
// Check Screen Width
const isWideScreen = window.innerWidth > 768;
const apiEndpoint = isWideScreen ? '/api/payment/native' : '/api/payment/h5';
try {
const res = await fetch(apiEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
});
const result = await res.json();
if (result.success) {
if (isWideScreen && result.code_url) {
// Show Native Pay QR Code
document.getElementById('qrModal').style.display = 'flex';
document.getElementById('qrcode').innerHTML = ''; // Clear previous
new QRCode(document.getElementById("qrcode"), {
text: result.code_url,
width: 200,
height: 200
});
// Start Polling
startPolling(result.out_trade_no);
} else if (result.h5_url) {
// H5 Redirect
showMessage('正在跳转支付...', 'success');
window.location.href = result.h5_url;
} else {
throw new Error('未获取到支付链接');
}
} else {
showMessage(result.message || '支付请求失败', 'error');
btn.innerHTML = originalText;
updateSubmitButtonState();
}
} catch (err) {
console.error(err);
showMessage(err.message || '网络请求错误,请重试', 'error');
btn.innerHTML = originalText;
updateSubmitButtonState();
}
}
function startPolling(out_trade_no) {
const statusDiv = document.getElementById('payment-status');
let attempts = 0;
const maxAttempts = 600; // 30 mins max
if (pollTimer) clearInterval(pollTimer);
pollTimer = setInterval(async () => {
attempts++;
if (attempts > maxAttempts) {
clearInterval(pollTimer);
statusDiv.textContent = '支付超时,请刷新重试';
return;
}
try {
const res = await fetch(`/api/payment/check/${out_trade_no}`);
const data = await res.json();
if (data.success && data.trade_state === 'SUCCESS') {
clearInterval(pollTimer);
statusDiv.textContent = '支付成功!正在跳转...';
statusDiv.style.color = '#28a745';
setTimeout(() => {
window.location.href = '/success';
}, 1000);
}
} catch (e) {
console.error("Polling error", e);
}
}, 3000);
}
function showMessage(text, type) {
const msg = document.getElementById('message');
msg.textContent = text;
msg.className = type;
msg.style.display = 'block';
}
async function sendSmsCode() {
const phone = document.getElementById('phone').value.trim();
if (!phone || !/^1[3-9]\d{9}$/.test(phone)) {
showMessage('请输入有效的手机号码', 'error');
return;
}
const btn = document.getElementById('send-code-btn');
const codeInput = document.getElementById('verification_code');
if (!btn) return;
btn.disabled = true;
const originalText = "发送验证码";
// 添加loading效果
btn.innerHTML = '<span class="loading" style="width: 14px; height: 14px; border-width: 2px; margin-right: 6px;"></span>发送中';
let count = 60;
try {
const res = await fetch('/api/send-sms', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({phone: phone})
});
const data = await res.json();
if (data.success) {
showMessage('验证码已发送', 'success');
// 成功后才允许输入验证码
if (codeInput) {
codeInput.disabled = false;
codeInput.placeholder = "请输入验证码";
codeInput.focus();
}
btn.innerText = `${count}s`;
const timer = setInterval(() => {
count--;
btn.innerText = `${count}s`;
if (count <= 0) {
clearInterval(timer);
btn.disabled = false;
btn.innerText = originalText;
}
}, 1000);
} else {
showMessage(data.message || '发送失败', 'error');
btn.disabled = false;
btn.innerText = originalText;
}
} catch (e) {
console.error(e);
showMessage('网络错误', 'error');
btn.disabled = false;
btn.innerText = originalText;
}
}
</script>
</body>
</html>

726
templates/wall.html Normal file
View File

@@ -0,0 +1,726 @@
<!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.6);
--text-color: #ffffff;
--header-img: url('{{ config.header_image }}');
--bg-opacity: {{ config.wall_config.bg_opacity if config.wall_config and config.wall_config.bg_opacity is not none else 0.3 }};
}
* {
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);
overflow: hidden;
height: 100vh;
width: 100vw;
position: relative;
}
/* Simplified Background */
.bg-layer {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-image: var(--header-img);
background-size: cover;
background-position: center;
opacity: var(--bg-opacity);
z-index: -3;
/* Dynamic blur: no blur if opacity is 1 */
filter: {{ 'none' if config.wall_config and config.wall_config.bg_opacity == 1 else 'blur(10px)' }};
}
/* Subtle overlay to ensure text contrast */
.clean-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.2);
z-index: -2;
}
/* Header */
header {
position: absolute;
top: 30px;
left: 0;
width: 100%;
text-align: center;
z-index: 20; /* Above bubbles */
pointer-events: none; /* Let clicks pass through */
}
h1 {
font-size: 3rem;
color: #ffffff;
font-weight: 900;
text-shadow: 0 0 20px rgba(0, 0, 0, 0.5);
margin: 0;
letter-spacing: 2px;
}
.subtitle {
font-size: 1.2rem;
color: var(--primary-color);
letter-spacing: 4px;
text-transform: uppercase;
margin-bottom: 10px;
opacity: 0.9;
text-shadow: 0 0 5px rgba(0, 0, 0, 0.5);
}
/* Container for User Bubbles */
#bubble-container {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1;
overflow: hidden;
}
.user-bubble {
position: absolute;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 60px;
height: 60px; /* Reduced from 80px */
transition: transform 0.8s cubic-bezier(0.2, 0.8, 0.2, 1), opacity 0.5s ease;
will-change: transform, left, top;
}
.bubble-avatar {
width: 100%; /* Fill container */
height: 100%; /* Fill container */
border-radius: 50%;
background: linear-gradient(135deg, var(--secondary-color), var(--primary-color));
border: 2px solid rgba(255, 255, 255, 0.8);
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
font-size: 0.9rem; /* Reduced font size */
color: #fff;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3);
margin-bottom: 0; /* Removed margin */
overflow: hidden;
text-align: center;
padding: 2px;
line-height: 1.1;
word-break: break-all;
}
.bubble-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.bubble-name {
font-size: 0.9rem;
color: #fff;
text-shadow: 0 2px 4px rgba(0,0,0,0.8);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100px;
text-align: center;
background: rgba(0,0,0,0.3);
padding: 2px 6px;
border-radius: 10px;
}
/* Central Spotlight Card */
.spotlight-container {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -45%);
width: 80%;
max-width: 900px;
z-index: 10;
pointer-events: none;
}
.spotlight-card {
background: rgba(10, 20, 40, 0.85);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 20px;
padding: 50px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
text-align: center;
opacity: 0;
transform: scale(0.9) translateY(20px);
transition: all 0.8s cubic-bezier(0.2, 0.8, 0.2, 1);
}
.spotlight-card.active {
opacity: 1;
transform: scale(1) translateY(0);
}
.user-info {
font-size: 1.8rem;
color: #b0c4de;
margin-bottom: 30px;
display: flex;
align-items: center;
justify-content: center;
gap: 15px;
}
.user-avatar-large {
width: 80px;
height: 80px;
border-radius: 50%;
background: linear-gradient(135deg, var(--secondary-color), var(--primary-color));
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
font-size: 2rem;
color: #fff;
border: 3px solid rgba(255,255,255,0.5);
}
.company-name {
font-size: 3rem;
font-weight: 800;
color: #fff;
margin-bottom: 20px;
background: linear-gradient(90deg, #fff, #b3d9ff);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
/* Stats Counter */
.stats-counter {
position: absolute;
bottom: 30px;
right: 40px;
text-align: right;
z-index: 20;
background: rgba(0,0,0,0.3);
padding: 10px 20px;
border-radius: 10px;
border: 1px solid rgba(255,255,255,0.1);
}
.stats-number {
font-size: 2rem;
font-weight: bold;
color: var(--primary-color);
}
.stats-label {
font-size: 0.8rem;
color: #aaa;
}
/* Mobile Learn More Button */
.mobile-btn-container {
display: none;
position: absolute;
bottom: 80px;
left: 50%;
transform: translateX(-50%);
z-index: 30;
width: 80%;
text-align: center;
}
.learn-more-btn {
background: linear-gradient(90deg, var(--secondary-color), var(--primary-color));
color: white;
padding: 12px 30px;
border-radius: 50px;
text-decoration: none;
font-weight: bold;
box-shadow: 0 5px 20px rgba(0, 242, 255, 0.4);
display: inline-block;
transition: transform 0.3s;
}
.learn-more-btn:active {
transform: scale(0.95);
}
/* Mobile Optimization */
@media (max-width: 768px) {
.mobile-btn-container {
display: block;
}
h1 {
font-size: 1.8rem;
}
.subtitle {
font-size: 0.8rem;
letter-spacing: 2px;
}
.spotlight-card {
padding: 20px;
width: 90%;
}
.company-name {
font-size: 1.5rem;
}
.user-info {
font-size: 1.2rem;
flex-direction: column;
gap: 5px;
}
.user-avatar-large {
width: 60px;
height: 60px;
font-size: 1.5rem;
}
#card-extra {
font-size: 1rem !important;
}
.stats-counter {
bottom: 10px;
right: 10px;
padding: 5px 10px;
}
.stats-number {
font-size: 1.5rem;
}
}
</style>
</head>
<body>
{% set wc = config.wall_config if config.wall_config else {} %}
{% if wc.show_title != false %}
<header>
<div class="subtitle">{{ config.event_sub_title }}</div>
<h1>{{ config.event_title }}</h1>
</header>
{% endif %}
<div class="bg-layer"></div>
<div class="clean-overlay"></div>
<div id="bubble-container"></div>
{% set wc = config.wall_config if config.wall_config else {} %}
{% set show = wc.show_fields if wc.show_fields else {} %}
<div class="spotlight-container">
<div class="spotlight-card" id="card">
<div class="user-info">
{% if show.name != false %}
<div class="user-avatar-large" id="card-avatar">?</div>
<div>
<span id="card-name" style="font-weight: bold; color: white;"></span>
{% if show.position != false %}
<span id="card-position" style="font-size: 0.8em; margin-left: 10px; color: var(--primary-color);"></span>
{% endif %}
</div>
{% endif %}
</div>
{% if show.company_name != false %}
<div class="company-name" id="card-company"></div>
{% endif %}
{% if show.vision_2026 != false or show.business_scope != false %}
<div id="card-extra" style="font-size: 1.4rem; color: #dbe4ff; font-style: italic; margin-top: 20px;"></div>
{% endif %}
</div>
</div>
{% if wc.learn_more_url %}
<div class="mobile-btn-container">
<a href="{{ wc.learn_more_url }}" class="learn-more-btn" target="_blank">了解我们</a>
</div>
{% endif %}
<div class="stats-counter">
<div class="stats-number" id="checkin-count">0</div>
<div class="stats-label">已签到嘉宾</div>
</div>
<script>
// --- Configuration ---
const CONFIG = {
gridThreshold: 20, // Switch to grid when users > 20
maxDisplay: 30, // Maximum bubbles to display at once
rotationInterval: 10000, // Rotation interval in ms (10s)
bubbleSize: 60, // Width of bubble
bubbleHeight: 60, // Height of bubble
speed: 1.5, // Floating speed
};
const isMobile = () => window.innerWidth <= 768;
const SHOW_VISION = {{ 'true' if show.vision_2026 != false else 'false' }};
const SHOW_SCOPE = {{ 'true' if show.business_scope != false else 'false' }};
// --- State ---
let allUsers = []; // All fetched users
let displayedUsers = []; // Currently displayed users
let bubbles = []; // Array of UserBubble instances
let currentIndex = 0; // For spotlight
let rotationIndex = 0; // For batch rotation
let rotationTimer = null;
let isGridMode = false;
const container = document.getElementById('bubble-container');
let width = window.innerWidth;
let height = window.innerHeight;
window.addEventListener('resize', () => {
width = window.innerWidth;
height = window.innerHeight;
if (isGridMode) arrangeGrid();
});
// --- Class: UserBubble ---
class UserBubble {
constructor(user) {
this.user = user;
this.id = user.gsdh_id || user.name + Math.random(); // Fallback ID
this.element = document.createElement('div');
this.element.className = 'user-bubble';
// Content
const name = user.name || '嘉宾';
// Dynamic font size based on name length
let fontSize = '0.9rem';
if (name.length > 3) fontSize = '0.7rem';
if (name.length > 4) fontSize = '0.6rem';
this.element.innerHTML = `
<div class="bubble-avatar" style="font-size: ${fontSize}">${name}</div>
`;
// Interaction: Click to show in spotlight
this.element.style.cursor = 'pointer';
this.element.addEventListener('click', (e) => {
e.stopPropagation();
showUserManual(this.user);
});
// Initial Fade In
this.element.style.opacity = '0';
container.appendChild(this.element);
// Trigger reflow for transition
requestAnimationFrame(() => {
this.element.style.opacity = '1';
});
// Initial Position (Random)
this.x = Math.random() * (width - CONFIG.bubbleSize);
this.y = Math.random() * (height - CONFIG.bubbleHeight);
// Velocity for floating
this.vx = (Math.random() - 0.5) * CONFIG.speed;
this.vy = (Math.random() - 0.5) * CONFIG.speed;
// Set initial DOM position
this.updatePosition();
}
updatePosition() {
this.element.style.transform = `translate(${this.x}px, ${this.y}px)`;
}
float() {
if (isGridMode) return; // Do not float in grid mode
this.x += this.vx;
this.y += this.vy;
// Bounce off edges
if (this.x <= 0 || this.x >= width - CONFIG.bubbleSize) this.vx *= -1;
if (this.y <= 0 || this.y >= height - CONFIG.bubbleHeight) this.vy *= -1;
this.updatePosition();
}
moveTo(targetX, targetY) {
// Use CSS transition for smooth movement to grid
this.x = targetX;
this.y = targetY;
this.updatePosition();
}
remove() {
this.element.style.opacity = '0';
this.element.style.transform = `translate(${this.x}px, ${this.y}px) scale(0.5)`;
setTimeout(() => {
if (this.element.parentNode) {
this.element.parentNode.removeChild(this.element);
}
}, 500); // Wait for transition
}
}
// --- Logic: Layout & Display Manager ---
function updateDisplay() {
// Determine which users to show
let targetUsers = [];
if (allUsers.length <= CONFIG.maxDisplay) {
targetUsers = [...allUsers];
// Reset rotation index if we fit in one page
rotationIndex = 0;
} else {
// Rotation Logic
// If rotationIndex is out of bounds, reset
if (rotationIndex >= allUsers.length) rotationIndex = 0;
// Get slice
targetUsers = allUsers.slice(rotationIndex, rotationIndex + CONFIG.maxDisplay);
// Wrap around if needed (though user asked for "batch", maybe wrapping to fill page is better?)
// If we want to maintain constant count:
if (targetUsers.length < CONFIG.maxDisplay) {
const remaining = CONFIG.maxDisplay - targetUsers.length;
targetUsers = targetUsers.concat(allUsers.slice(0, remaining));
}
}
displayedUsers = targetUsers;
syncBubbles(targetUsers);
// Update Grid/Float mode based on DISPLAYED count (usually maxDisplay if full)
// But config says switch to grid when users > 20.
// If maxDisplay is 30, then 20-30 will be grid.
const count = displayedUsers.length;
const wasGrid = isGridMode;
isGridMode = count > CONFIG.gridThreshold;
if (isGridMode) {
arrangeGrid();
} else if (wasGrid && !isGridMode) {
// Switching back to float, ensure they have random velocities if needed
// But typically we don't go back from grid to float unless people leave.
}
}
function syncBubbles(targetUsers) {
const targetIds = new Set(targetUsers.map(u => u.gsdh_id || u.name));
// 1. Remove bubbles not in target
// Filter bubbles in place
const keptBubbles = [];
for (const b of bubbles) {
if (!targetIds.has(b.id)) {
b.remove();
} else {
keptBubbles.push(b);
}
}
bubbles = keptBubbles;
// 2. Add new bubbles
const currentIds = new Set(bubbles.map(b => b.id));
for (const u of targetUsers) {
const uid = u.gsdh_id || u.name;
if (!currentIds.has(uid)) {
bubbles.push(new UserBubble(u));
}
}
// Re-arrange if grid
if (isGridMode) {
// Delay slightly to let new bubbles exist in DOM?
// arrangeGrid uses 'bubbles' array, so it's fine.
// We might want to call arrangeGrid immediately.
setTimeout(arrangeGrid, 50);
}
}
function arrangeGrid() {
// Simple Grid Algorithm
const count = bubbles.length;
if (count === 0) return;
const cols = Math.ceil(Math.sqrt(count * (width / height)));
const rows = Math.ceil(count / cols);
const cellW = width / cols;
const cellH = height / rows;
bubbles.forEach((bubble, index) => {
const col = index % cols;
const row = Math.floor(index / cols);
// Center in cell
const targetX = col * cellW + (cellW - CONFIG.bubbleSize) / 2;
const targetY = row * cellH + (cellH - CONFIG.bubbleHeight) / 2;
bubble.moveTo(targetX, targetY);
});
}
function startRotationTimer() {
if (rotationTimer) clearInterval(rotationTimer);
rotationTimer = setInterval(() => {
if (allUsers.length > CONFIG.maxDisplay) {
rotationIndex += CONFIG.maxDisplay;
if (rotationIndex >= allUsers.length) rotationIndex = 0;
updateDisplay();
}
}, CONFIG.rotationInterval);
}
// --- Animation Loop ---
function animate() {
if (!isGridMode) {
bubbles.forEach(b => b.float());
}
requestAnimationFrame(animate);
}
animate(); // Start loop
// --- Data Fetching ---
async function fetchData() {
try {
const res = await fetch('/api/wall/data');
const json = await res.json();
if (json.success) {
const fetchedUsers = json.data;
document.getElementById('checkin-count').textContent = fetchedUsers.length;
// Check if new data arrived
if (fetchedUsers.length !== allUsers.length) {
const oldCount = allUsers.length;
allUsers = fetchedUsers;
// If we have more users than before
if (allUsers.length > oldCount) {
// Strategy: If we are rotating, jump to the NEWEST users immediately
// The API returns users sorted by created_at DESC (Newest first).
// So new users are at index 0.
// Wait, API says: ORDER BY created_at DESC
// So new users are at the TOP (0).
// If we want to show new users immediately:
rotationIndex = 0; // Show the first batch (newest)
updateDisplay();
// Reset timer to keep this view for a full interval
startRotationTimer();
// Trigger spotlight for newest
if (currentIndex === 0 && allUsers.length > 0 && !isMobile()) {
// Maybe spotlight the very first one
updateCard(allUsers[0]);
// Restart spotlight loop
startSpotlight();
}
} else {
// Users removed? Just update
updateDisplay();
}
} else {
// Data didn't change count, but maybe content changed?
// For simplicity, assume count change triggers update.
// Or if it's the first load
if (bubbles.length === 0 && allUsers.length > 0) {
updateDisplay();
startRotationTimer();
if (!isMobile()) startSpotlight();
}
}
}
} catch (e) {
console.error("Fetch error", e);
}
}
// --- Spotlight Logic ---
let spotlightInterval = null;
function updateCard(user) {
const card = document.getElementById('card');
card.classList.remove('active');
// Wait for fade out
setTimeout(() => {
if(document.getElementById('card-name')) document.getElementById('card-name').textContent = user.name;
if(document.getElementById('card-position')) document.getElementById('card-position').textContent = user.position || '';
if(document.getElementById('card-company')) document.getElementById('card-company').textContent = user.company_name || '嘉宾';
if(document.getElementById('card-avatar')) document.getElementById('card-avatar').textContent = user.name ? user.name.charAt(0) : '?';
const extraDiv = document.getElementById('card-extra');
if (extraDiv) {
if (SHOW_VISION && user.vision_2026) {
extraDiv.textContent = "🎯 " + user.vision_2026;
extraDiv.style.display = 'block';
} else if (SHOW_SCOPE && user.business_scope) {
extraDiv.textContent = user.business_scope;
extraDiv.style.display = 'block';
} else {
extraDiv.style.display = 'none';
}
}
card.classList.add('active');
}, 800);
}
function startSpotlight() {
if (isMobile()) return;
if (spotlightInterval) clearInterval(spotlightInterval);
showNextUser();
spotlightInterval = setInterval(showNextUser, 8000);
}
function showNextUser() {
if (displayedUsers.length === 0) return;
const user = displayedUsers[currentIndex];
currentIndex = (currentIndex + 1) % displayedUsers.length;
updateCard(user);
}
function showUserManual(user) {
// Pause auto-rotation
if (spotlightInterval) clearInterval(spotlightInterval);
// Show clicked user immediately
updateCard(user);
// Resume auto-rotation after 15 seconds (only on desktop)
if (!isMobile()) {
spotlightInterval = setTimeout(() => {
startSpotlight();
}, 15000);
}
}
// Initial Load
fetchData();
setInterval(fetchData, 5000); // Check every 5s
</script>
</body>
</html>

1
vercel.json Normal file
View File

@@ -0,0 +1 @@
{"rewrites":[{"source":"/(.*)","destination":"/index.html"}]}