init
This commit is contained in:
3416
Cargo.lock
generated
Normal file
3416
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
30
Cargo.toml
Normal file
30
Cargo.toml
Normal file
@ -0,0 +1,30 @@
|
||||
[package]
|
||||
name = "qiandao"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
default-run = "qiandao"
|
||||
|
||||
[dependencies]
|
||||
axum = { version = "0.6", features = ["multipart"] }
|
||||
tokio = { version = "1.0", features = ["full"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
sqlx = { version = "0.6", features = ["runtime-tokio-rustls", "mysql", "chrono", "uuid"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
uuid = { version = "1.0", features = ["v4", "serde"] }
|
||||
tower-http = { version = "0.4", features = ["cors", "trace", "fs"] }
|
||||
dotenv = "0.15"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
bcrypt = "0.14"
|
||||
jsonwebtoken = "8.3"
|
||||
calamine = "0.22"
|
||||
xlsxwriter = "0.6"
|
||||
rust_xlsxwriter = "0.78.0"
|
||||
tower = { version = "0.4", features = ["util"] }
|
||||
bytes = "1.0"
|
||||
futures = "0.3"
|
||||
|
||||
[dependencies.sea-orm]
|
||||
version = "0.11"
|
||||
features = ["sqlx-mysql", "runtime-tokio-rustls", "macros"]
|
||||
136
README.md
136
README.md
@ -1,2 +1,136 @@
|
||||
# qiandao
|
||||
# 会议签到系统
|
||||
|
||||
一个基于 Rust (Axum + SeaORM) 和 Ant Design 的会议签到系统。
|
||||
|
||||
## 功能特性
|
||||
|
||||
- 参会者注册
|
||||
- 参会者信息管理
|
||||
- 签到功能
|
||||
- 参会者列表查看
|
||||
- 搜索功能
|
||||
|
||||
## 技术栈
|
||||
|
||||
### 后端
|
||||
- Rust
|
||||
- Axum (Web 框架)
|
||||
- SeaORM (数据库 ORM)
|
||||
- MySQL (数据库)
|
||||
|
||||
### 前端
|
||||
- React 18
|
||||
- Ant Design
|
||||
- Vite (构建工具)
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 环境要求
|
||||
|
||||
- Rust 1.65+
|
||||
- Node.js 16+
|
||||
- Docker 和 Docker Compose (可选,用于数据库)
|
||||
|
||||
### 启动数据库
|
||||
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
### 设置数据库
|
||||
|
||||
创建数据库:
|
||||
```sql
|
||||
CREATE DATABASE qiandao CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
```
|
||||
|
||||
运行迁移脚本:
|
||||
```bash
|
||||
mysql -u root -p qiandao < migrations/001_create_attendees_table.sql
|
||||
```
|
||||
|
||||
### 启动后端服务
|
||||
|
||||
```bash
|
||||
cargo run
|
||||
```
|
||||
|
||||
后端服务将在 `http://localhost:3000` 运行。
|
||||
|
||||
### 启动前端开发服务器
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
前端开发服务器将在 `http://localhost:5173` 运行。
|
||||
|
||||
## API 接口
|
||||
|
||||
### 参会者相关
|
||||
|
||||
- `POST /api/attendees` - 创建参会者
|
||||
- `GET /api/attendees` - 获取所有参会者
|
||||
- `GET /api/attendees/:id` - 获取指定参会者
|
||||
- `POST /api/attendees/:id/checkin` - 为参会者签到
|
||||
|
||||
## 部署
|
||||
|
||||
### 构建前端
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm run build
|
||||
```
|
||||
|
||||
构建产物将位于 `frontend/dist` 目录中。
|
||||
|
||||
### 构建后端
|
||||
|
||||
```bash
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
.
|
||||
├── Cargo.toml
|
||||
├── src/
|
||||
│ ├── main.rs
|
||||
│ ├── database.rs
|
||||
│ ├── models.rs
|
||||
│ └── routes.rs
|
||||
├── migrations/
|
||||
│ └── 001_create_attendees_table.sql
|
||||
├── frontend/
|
||||
│ ├── package.json
|
||||
│ ├── vite.config.js
|
||||
│ ├── index.html
|
||||
│ ├── src/
|
||||
│ │ ├── main.jsx
|
||||
│ │ ├── App.jsx
|
||||
│ │ ├── components/
|
||||
│ │ │ ├── AttendeeRegistration.jsx
|
||||
│ │ │ ├── AttendeeList.jsx
|
||||
│ │ │ └── CheckinPage.jsx
|
||||
└── docker-compose.yml
|
||||
```
|
||||
|
||||
## 开发指南
|
||||
|
||||
### 添加新的 API 接口
|
||||
|
||||
1. 在 [routes.rs](file:///Users/yangshiyou/Develop/code/rust/qiandao/src/routes.rs) 中添加处理函数
|
||||
2. 在 [main.rs](file:///Users/yangshiyou/Develop/code/rust/qiandao/src/main.rs) 中注册路由
|
||||
|
||||
### 修改数据库模型
|
||||
|
||||
1. 更新 [models.rs](file:///Users/yangshiyou/Develop/code/rust/qiandao/src/models.rs) 中的模型定义
|
||||
2. 创建新的迁移脚本
|
||||
|
||||
## 许可证
|
||||
|
||||
MIT
|
||||
23
create_db.py
Normal file
23
create_db.py
Normal file
@ -0,0 +1,23 @@
|
||||
import mysql.connector
|
||||
|
||||
try:
|
||||
# Connect to MySQL server
|
||||
conn = mysql.connector.connect(
|
||||
host='127.0.0.1',
|
||||
user='root',
|
||||
password='123456',
|
||||
port=3306
|
||||
)
|
||||
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Create database
|
||||
cursor.execute("CREATE DATABASE IF NOT EXISTS qiandao CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci")
|
||||
|
||||
print("Database 'qiandao' created successfully!")
|
||||
|
||||
cursor.close()
|
||||
conn.close()
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
28
docker-compose.yml
Normal file
28
docker-compose.yml
Normal file
@ -0,0 +1,28 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
mysql:
|
||||
image: mysql:8.0
|
||||
container_name: qiandao-mysql
|
||||
restart: always
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: password
|
||||
MYSQL_DATABASE: qiandao
|
||||
MYSQL_USER: qiandao
|
||||
MYSQL_PASSWORD: qiandao123
|
||||
ports:
|
||||
- "3306:3306"
|
||||
volumes:
|
||||
- mysql_data:/var/lib/mysql
|
||||
- ./migrations:/docker-entrypoint-initdb.d
|
||||
command: --default-authentication-plugin=mysql_native_password
|
||||
|
||||
adminer:
|
||||
image: adminer
|
||||
container_name: qiandao-adminer
|
||||
restart: always
|
||||
ports:
|
||||
- "8080:8080"
|
||||
|
||||
volumes:
|
||||
mysql_data:
|
||||
24
fix_config_data.sql
Normal file
24
fix_config_data.sql
Normal file
@ -0,0 +1,24 @@
|
||||
-- 修复配置数据的SQL脚本
|
||||
-- 确保使用正确的UTF-8字符编码
|
||||
|
||||
-- 设置连接字符集
|
||||
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
SET character_set_client = utf8mb4;
|
||||
SET character_set_connection = utf8mb4;
|
||||
SET character_set_results = utf8mb4;
|
||||
|
||||
-- 清空现有数据
|
||||
DELETE FROM config;
|
||||
|
||||
-- 重新插入正确的配置数据
|
||||
INSERT INTO config (config_key, config_value, description) VALUES
|
||||
('main_title', '医疗协会年度大会签到', '首页主标题'),
|
||||
('subtitle', '欢迎参加2024年医疗协会年度大会,请完成签到', '首页副标题'),
|
||||
('success_title', '签到成功', '签到成功页面标题'),
|
||||
('success_message', '欢迎参加本次会议,祝您会议愉快!', '签到成功消息'),
|
||||
('already_checked_title', '您已签到', '重复签到页面标题'),
|
||||
('already_checked_message', '您之前已经完成签到,无需重复签到。祝您会议愉快!', '重复签到消息'),
|
||||
('success_main_title', '签到成功', '签到成功后的主标题');
|
||||
|
||||
-- 验证插入的数据
|
||||
SELECT id, config_key, config_value, description FROM config;
|
||||
BIN
frontend/.DS_Store
vendored
Normal file
BIN
frontend/.DS_Store
vendored
Normal file
Binary file not shown.
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>会议签到系统</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
2525
frontend/package-lock.json
generated
Normal file
2525
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
frontend/package.json
Normal file
24
frontend/package.json
Normal file
@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "qiandao-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"antd": "^5.27.6",
|
||||
"axios": "^1.0.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.0.0",
|
||||
"@types/react-dom": "^18.0.0",
|
||||
"@vitejs/plugin-react": "^4.0.0",
|
||||
"vite": "^4.0.0"
|
||||
}
|
||||
}
|
||||
582
frontend/public/checkin.html
Normal file
582
frontend/public/checkin.html
Normal file
@ -0,0 +1,582 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>会议签到</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.container {
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 15px 35px rgba(0, 0, 0, 0.1);
|
||||
padding: 40px;
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
color: #333;
|
||||
font-size: 28px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.header p {
|
||||
color: #666;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.form-group.required label::after {
|
||||
content: " *";
|
||||
color: #e74c3c;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
width: 100%;
|
||||
padding: 12px 15px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 5px;
|
||||
font-size: 16px;
|
||||
transition: border-color 0.3s;
|
||||
}
|
||||
|
||||
.form-control:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.2);
|
||||
}
|
||||
|
||||
.btn {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
background: #ccc;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.message {
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
margin-bottom: 20px;
|
||||
text-align: center;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.message.success {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
border: 1px solid #c3e6cb;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.message.error {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
border: 1px solid #f5c6cb;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.success-page {
|
||||
text-align: center;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.success-page.show {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.success-icon {
|
||||
font-size: 80px;
|
||||
color: #28a745;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.success-title {
|
||||
font-size: 28px;
|
||||
color: #333;
|
||||
margin-bottom: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.success-message {
|
||||
font-size: 18px;
|
||||
color: #666;
|
||||
margin-bottom: 30px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.success-info {
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-bottom: 30px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.success-info h3 {
|
||||
color: #333;
|
||||
margin-bottom: 15px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.info-item:last-child {
|
||||
border-bottom: none;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
font-weight: 500;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #6c757d;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 5px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #5a6268;
|
||||
}
|
||||
|
||||
.footer {
|
||||
text-align: center;
|
||||
margin-top: 20px;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.container {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1 id="mainTitle">会议签到</h1>
|
||||
<p id="headerSubtitle">请填写以下信息完成签到</p>
|
||||
</div>
|
||||
|
||||
<div id="message" class="message"></div>
|
||||
|
||||
<!-- 签到表单 -->
|
||||
<div id="checkinFormContainer">
|
||||
<form id="checkinForm">
|
||||
<div class="form-group required">
|
||||
<label for="name">姓名</label>
|
||||
<input type="text" id="name" class="form-control" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group required">
|
||||
<label for="phone">手机号</label>
|
||||
<input type="tel" id="phone" class="form-control" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="company">公司</label>
|
||||
<input type="text" id="company" class="form-control">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="position">职位</label>
|
||||
<input type="text" id="position" class="form-control">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="email">邮箱</label>
|
||||
<input type="email" id="email" class="form-control">
|
||||
</div>
|
||||
|
||||
<button type="submit" id="submitBtn" class="btn">签到</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- 签到成功页面 -->
|
||||
<div id="successPage" class="success-page">
|
||||
<div class="success-icon">✓</div>
|
||||
<div class="success-title">签到成功!</div>
|
||||
<div class="success-message">欢迎参加本次会议,祝您会议愉快!</div>
|
||||
|
||||
<div class="success-info">
|
||||
<h3>您的签到信息</h3>
|
||||
<div class="info-item">
|
||||
<span class="info-label">姓名:</span>
|
||||
<span class="info-value" id="successName">-</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">手机号:</span>
|
||||
<span class="info-value" id="successPhone">-</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">公司:</span>
|
||||
<span class="info-value" id="successCompany">-</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">职位:</span>
|
||||
<span class="info-value" id="successPosition">-</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">邮箱:</span>
|
||||
<span class="info-value" id="successEmail">-</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">签到时间:</span>
|
||||
<span class="info-value" id="successTime">-</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" class="btn-secondary" onclick="resetToForm()">重新签到</button>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p>© <span id="currentYear"></span> 道友签到系统</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// 全局配置对象
|
||||
let appConfig = {
|
||||
main_title: '会议签到',
|
||||
subtitle: '请填写以下信息完成签到',
|
||||
success_title: '签到成功',
|
||||
success_message: '欢迎参加本次会议,祝您会议愉快!',
|
||||
already_checked_title: '您已签到',
|
||||
already_checked_message: '您之前已经完成签到,无需重复签到。祝您会议愉快!',
|
||||
success_main_title: '签到成功'
|
||||
};
|
||||
|
||||
// 检测当前环境并设置API基础URL
|
||||
const getApiBaseUrl = () => {
|
||||
if (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') {
|
||||
return 'http://127.0.0.1:3001';
|
||||
} else {
|
||||
// 在生产环境中,您可以设置实际的API地址
|
||||
return window.location.origin;
|
||||
}
|
||||
};
|
||||
|
||||
const API_BASE_URL = getApiBaseUrl();
|
||||
|
||||
// 从API加载配置
|
||||
async function loadConfig() {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/api/config`);
|
||||
if (response.ok) {
|
||||
const configs = await response.json();
|
||||
// 更新全局配置对象
|
||||
configs.forEach(config => {
|
||||
if (appConfig.hasOwnProperty(config.config_key)) {
|
||||
appConfig[config.config_key] = config.config_value;
|
||||
}
|
||||
});
|
||||
// 更新页面显示
|
||||
updatePageContent();
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('加载配置失败,使用默认配置:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 更新页面内容
|
||||
function updatePageContent() {
|
||||
document.getElementById('mainTitle').textContent = appConfig.main_title;
|
||||
document.getElementById('headerSubtitle').textContent = appConfig.subtitle;
|
||||
}
|
||||
|
||||
// 页面加载时初始化配置
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadConfig();
|
||||
});
|
||||
|
||||
document.getElementById('checkinForm').addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
// 获取表单数据
|
||||
const formData = {
|
||||
name: document.getElementById('name').value,
|
||||
phone: document.getElementById('phone').value,
|
||||
email: document.getElementById('email').value || null,
|
||||
company: document.getElementById('company').value || null,
|
||||
position: document.getElementById('position').value || null
|
||||
};
|
||||
|
||||
// 简单验证
|
||||
if (!formData.name || !formData.phone) {
|
||||
showMessage('请填写姓名和手机号', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// 简单手机号验证
|
||||
if (!/^1[3-9]\d{9}$/.test(formData.phone)) {
|
||||
showMessage('请输入正确的手机号格式', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// 简单邮箱验证
|
||||
if (formData.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
|
||||
showMessage('请输入正确的邮箱格式', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// 禁用提交按钮
|
||||
const submitBtn = document.getElementById('submitBtn');
|
||||
const originalText = submitBtn.textContent;
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = '签到中...';
|
||||
|
||||
try {
|
||||
// 首先尝试通过邮箱或手机号查找用户
|
||||
let attendeeId = null;
|
||||
let isExistingUser = false;
|
||||
|
||||
// 获取所有参会者列表来查找匹配的用户
|
||||
const listResponse = await fetch(API_BASE_URL + '/api/attendees?page_size=1000');
|
||||
if (listResponse.ok) {
|
||||
const response = await listResponse.json();
|
||||
const attendees = response.data; // 从分页响应中获取data数组
|
||||
const existingAttendee = attendees.find(a =>
|
||||
formData.phone && a.phone === formData.phone && formData.name === a.name
|
||||
);
|
||||
|
||||
if (existingAttendee) {
|
||||
attendeeId = existingAttendee.id;
|
||||
isExistingUser = true;
|
||||
|
||||
// 如果已经签到过了
|
||||
if (existingAttendee.checked_in) {
|
||||
showSuccessPage(formData, existingAttendee, true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isExistingUser && attendeeId) {
|
||||
// 用户已存在,直接签到
|
||||
const checkinResponse = await fetch(API_BASE_URL + `/api/attendees/${attendeeId}/checkin`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (checkinResponse.ok) {
|
||||
const result = await checkinResponse.json();
|
||||
showSuccessPage(formData, result);
|
||||
} else {
|
||||
const errorText = await checkinResponse.text();
|
||||
showMessage('签到失败:' + (errorText || '服务器返回错误状态码: ' + checkinResponse.status), 'error');
|
||||
}
|
||||
} else {
|
||||
// 用户不存在,先注册再签到
|
||||
const registerResponse = await fetch(API_BASE_URL + '/api/attendees', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(formData)
|
||||
});
|
||||
|
||||
if (registerResponse.ok) {
|
||||
const newUser = await registerResponse.json();
|
||||
|
||||
// 注册成功后立即签到
|
||||
const checkinResponse = await fetch(API_BASE_URL + `/api/attendees/${newUser.id}/checkin`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (checkinResponse.ok) {
|
||||
const result = await checkinResponse.json();
|
||||
showSuccessPage(formData, result);
|
||||
} else {
|
||||
showMessage('注册成功但签到失败,请联系管理员。', 'error');
|
||||
}
|
||||
} else {
|
||||
const errorText = await registerResponse.text();
|
||||
showMessage('注册失败:' + (errorText || '服务器返回错误状态码: ' + registerResponse.status), 'error');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Network error details:', error);
|
||||
showMessage('网络错误,请稍后重试。错误详情: ' + error.message, 'error');
|
||||
} finally {
|
||||
// 恢复提交按钮
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = originalText;
|
||||
}
|
||||
});
|
||||
|
||||
function showMessage(text, type) {
|
||||
const messageEl = document.getElementById('message');
|
||||
messageEl.textContent = text;
|
||||
messageEl.className = 'message ' + type;
|
||||
|
||||
// 3秒后自动隐藏成功消息
|
||||
if (type === 'success') {
|
||||
setTimeout(() => {
|
||||
messageEl.style.display = 'none';
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
|
||||
function showSuccessPage(formData, result, isAlreadyCheckedIn = false) {
|
||||
// 隐藏表单和消息
|
||||
document.getElementById('checkinFormContainer').style.display = 'none';
|
||||
document.getElementById('message').style.display = 'none';
|
||||
document.getElementById('headerSubtitle').style.display = 'none';
|
||||
|
||||
// 更新主标题为配置中的成功标题
|
||||
const mainTitle = document.getElementById('mainTitle');
|
||||
mainTitle.textContent = appConfig.success_main_title;
|
||||
|
||||
// 填充成功页面信息
|
||||
document.getElementById('successName').textContent = formData.name || result.name || '-';
|
||||
document.getElementById('successPhone').textContent = formData.phone || result.phone || '-';
|
||||
document.getElementById('successCompany').textContent = formData.company || result.company || '-';
|
||||
document.getElementById('successPosition').textContent = formData.position || result.position || '-';
|
||||
document.getElementById('successEmail').textContent = formData.email || result.email || '-';
|
||||
|
||||
// 格式化签到时间
|
||||
let checkinTime = '-';
|
||||
if (result.checkin_time) {
|
||||
const date = new Date(result.checkin_time);
|
||||
// 后端已经返回北京时间,直接格式化显示
|
||||
checkinTime = date.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
});
|
||||
} else {
|
||||
// 如果没有签到时间,使用当前时间
|
||||
checkinTime = new Date().toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
});
|
||||
}
|
||||
document.getElementById('successTime').textContent = checkinTime;
|
||||
|
||||
// 更新标题和消息
|
||||
const titleEl = document.querySelector('.success-title');
|
||||
const messageEl = document.querySelector('.success-message');
|
||||
|
||||
if (isAlreadyCheckedIn) {
|
||||
titleEl.textContent = appConfig.already_checked_title;
|
||||
messageEl.textContent = appConfig.already_checked_message;
|
||||
} else {
|
||||
titleEl.textContent = appConfig.success_title;
|
||||
messageEl.textContent = appConfig.success_message;
|
||||
}
|
||||
|
||||
// 显示成功页面
|
||||
document.getElementById('successPage').classList.add('show');
|
||||
}
|
||||
|
||||
function resetToForm() {
|
||||
// 隐藏成功页面
|
||||
document.getElementById('successPage').classList.remove('show');
|
||||
|
||||
// 恢复原始标题
|
||||
document.getElementById('mainTitle').textContent = appConfig.main_title;
|
||||
|
||||
// 显示表单和副标题
|
||||
document.getElementById('checkinFormContainer').style.display = 'block';
|
||||
document.getElementById('headerSubtitle').style.display = 'block';
|
||||
|
||||
// 重置表单
|
||||
document.getElementById('checkinForm').reset();
|
||||
|
||||
// 隐藏消息
|
||||
document.getElementById('message').style.display = 'none';
|
||||
}
|
||||
|
||||
// 设置当前年份
|
||||
document.getElementById('currentYear').textContent = new Date().getFullYear();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
17
frontend/src/App.css
Normal file
17
frontend/src/App.css
Normal file
@ -0,0 +1,17 @@
|
||||
.logo {
|
||||
font-weight: bold;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.site-layout .site-layout-background {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.ant-card {
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.header-title {
|
||||
margin: 0 !important;
|
||||
}
|
||||
137
frontend/src/App.jsx
Normal file
137
frontend/src/App.jsx
Normal file
@ -0,0 +1,137 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Layout, Menu, Typography, Card, Button, message, theme } from 'antd';
|
||||
import { UsergroupAddOutlined, TeamOutlined, AppstoreOutlined, LogoutOutlined } from '@ant-design/icons';
|
||||
import AttendeeRegistration from './components/AttendeeRegistration';
|
||||
import AttendeeList from './components/AttendeeList';
|
||||
import CheckinPage from './components/CheckinPage';
|
||||
import Login from './components/Login';
|
||||
import './App.css';
|
||||
|
||||
const { Header, Content, Footer, Sider } = Layout;
|
||||
const { Title } = Typography;
|
||||
|
||||
function App() {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [currentView, setCurrentView] = useState('registration');
|
||||
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
||||
|
||||
const {
|
||||
token: { colorBgContainer },
|
||||
} = theme.useToken();
|
||||
|
||||
// 检查登录状态
|
||||
useEffect(() => {
|
||||
const loginStatus = localStorage.getItem('isLoggedIn');
|
||||
const loginTime = localStorage.getItem('loginTime');
|
||||
|
||||
if (loginStatus === 'true' && loginTime) {
|
||||
// 检查登录是否过期(24小时)
|
||||
const now = Date.now();
|
||||
const loginTimestamp = parseInt(loginTime);
|
||||
const twentyFourHours = 24 * 60 * 60 * 1000;
|
||||
|
||||
if (now - loginTimestamp < twentyFourHours) {
|
||||
setIsLoggedIn(true);
|
||||
} else {
|
||||
// 登录过期,清除状态
|
||||
localStorage.removeItem('isLoggedIn');
|
||||
localStorage.removeItem('loginTime');
|
||||
setIsLoggedIn(false);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleLogin = (status) => {
|
||||
setIsLoggedIn(status);
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem('isLoggedIn');
|
||||
localStorage.removeItem('loginTime');
|
||||
setIsLoggedIn(false);
|
||||
setCurrentView('registration');
|
||||
message.success('已退出登录');
|
||||
};
|
||||
|
||||
// 如果未登录,显示登录页面
|
||||
if (!isLoggedIn) {
|
||||
return <Login onLogin={handleLogin} />;
|
||||
}
|
||||
|
||||
const renderCurrentView = () => {
|
||||
switch(currentView) {
|
||||
case 'registration':
|
||||
return <AttendeeRegistration />;
|
||||
case 'attendees':
|
||||
return <AttendeeList />;
|
||||
case 'checkin':
|
||||
return <CheckinPage />;
|
||||
default:
|
||||
return <AttendeeRegistration />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout style={{ minHeight: '100vh' }}>
|
||||
<Sider collapsible collapsed={collapsed} onCollapse={(value) => setCollapsed(value)}>
|
||||
<div className="logo" style={{
|
||||
height: '32px',
|
||||
margin: '16px',
|
||||
background: 'rgba(255, 255, 255, 0.2)',
|
||||
color: 'white',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}>
|
||||
签到系统
|
||||
</div>
|
||||
<Menu
|
||||
theme="dark"
|
||||
defaultSelectedKeys={['registration']}
|
||||
mode="inline"
|
||||
onSelect={({ key }) => setCurrentView(key)}
|
||||
items={[
|
||||
{
|
||||
key: 'registration',
|
||||
icon: <UsergroupAddOutlined />,
|
||||
label: '参会者注册',
|
||||
},
|
||||
{
|
||||
key: 'attendees',
|
||||
icon: <TeamOutlined />,
|
||||
label: '参会者列表',
|
||||
},
|
||||
{
|
||||
key: 'checkin',
|
||||
icon: <AppstoreOutlined />,
|
||||
label: '签到管理',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Sider>
|
||||
<Layout>
|
||||
<Header style={{ padding: 0, background: colorBgContainer, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Title level={3} style={{ margin: '0 20px' }}>会议签到系统</Title>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<LogoutOutlined />}
|
||||
onClick={handleLogout}
|
||||
style={{ marginRight: 20 }}
|
||||
>
|
||||
退出登录
|
||||
</Button>
|
||||
</Header>
|
||||
<Content style={{ margin: '16px' }}>
|
||||
<Card style={{ minHeight: 360 }}>
|
||||
{renderCurrentView()}
|
||||
</Card>
|
||||
</Content>
|
||||
<Footer style={{ textAlign: 'center' }}>
|
||||
会议签到系统 ©2023
|
||||
</Footer>
|
||||
</Layout>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
582
frontend/src/components/AttendeeList.jsx
Normal file
582
frontend/src/components/AttendeeList.jsx
Normal file
@ -0,0 +1,582 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Table, Button, message, Typography, Space, Popconfirm, Input, Modal, Form, Upload } from 'antd';
|
||||
import { ReloadOutlined, DeleteOutlined, SearchOutlined, PlusOutlined, UploadOutlined, DownloadOutlined } from '@ant-design/icons';
|
||||
import axios from 'axios';
|
||||
import * as XLSX from 'xlsx';
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
const AttendeeList = () => {
|
||||
const [attendees, setAttendees] = useState([]);
|
||||
const [filteredAttendees, setFilteredAttendees] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState([]);
|
||||
const [pagination, setPagination] = useState({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
});
|
||||
|
||||
// 新增参会者相关状态
|
||||
const [isModalVisible, setIsModalVisible] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
const [addLoading, setAddLoading] = useState(false);
|
||||
|
||||
// Excel导入相关状态
|
||||
const [isImportModalVisible, setIsImportModalVisible] = useState(false);
|
||||
const [importLoading, setImportLoading] = useState(false);
|
||||
const [fileList, setFileList] = useState([]);
|
||||
|
||||
// Excel导出相关状态
|
||||
const [exportLoading, setExportLoading] = useState(false);
|
||||
|
||||
const fetchAttendees = async (page = 1, pageSize = 10) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
console.log(`Fetching attendees from /api/attendees?page=${page}&page_size=${pageSize}`);
|
||||
const response = await axios.get(`/api/attendees?page=${page}&page_size=${pageSize}`);
|
||||
console.log('Received response:', response.data);
|
||||
|
||||
setAttendees(response.data.data);
|
||||
setFilteredAttendees(response.data.data);
|
||||
setPagination({
|
||||
current: response.data.page,
|
||||
pageSize: response.data.page_size,
|
||||
total: response.data.total,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching attendees:', error);
|
||||
message.error('获取参会者列表失败: ' + (error.response?.data || error.message || '未知错误'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 搜索过滤逻辑
|
||||
useEffect(() => {
|
||||
if (!searchText) {
|
||||
setFilteredAttendees(attendees);
|
||||
} else {
|
||||
const filtered = attendees.filter(attendee =>
|
||||
attendee.name.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||
attendee.email.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||
(attendee.phone && attendee.phone.includes(searchText)) ||
|
||||
(attendee.company && attendee.company.toLowerCase().includes(searchText.toLowerCase())) ||
|
||||
(attendee.position && attendee.position.toLowerCase().includes(searchText.toLowerCase()))
|
||||
);
|
||||
setFilteredAttendees(filtered);
|
||||
}
|
||||
}, [searchText, attendees]);
|
||||
|
||||
const handleBatchDelete = async () => {
|
||||
if (selectedRowKeys.length === 0) {
|
||||
message.warning('请选择要删除的参会者');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.delete('/api/attendees/batch-delete', {
|
||||
data: { ids: selectedRowKeys }
|
||||
});
|
||||
|
||||
message.success(`成功删除 ${selectedRowKeys.length} 个参会者`);
|
||||
setSelectedRowKeys([]);
|
||||
|
||||
// 重新获取数据
|
||||
await fetchAttendees(pagination.current, pagination.pageSize);
|
||||
} catch (error) {
|
||||
console.error('Error deleting attendees:', error);
|
||||
message.error('删除失败: ' + (error.response?.data || error.message || '未知错误'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleTableChange = (paginationInfo) => {
|
||||
fetchAttendees(paginationInfo.current, paginationInfo.pageSize);
|
||||
};
|
||||
|
||||
// 新增参会者相关函数
|
||||
const showAddModal = () => {
|
||||
setIsModalVisible(true);
|
||||
};
|
||||
|
||||
const handleAddCancel = () => {
|
||||
setIsModalVisible(false);
|
||||
form.resetFields();
|
||||
};
|
||||
|
||||
const handleAddSubmit = async (values) => {
|
||||
setAddLoading(true);
|
||||
try {
|
||||
console.log('Adding attendee with values:', values);
|
||||
const response = await axios.post('/api/attendees', values);
|
||||
console.log('Add attendee response:', response.data);
|
||||
message.success('参会者添加成功!');
|
||||
form.resetFields();
|
||||
setIsModalVisible(false);
|
||||
|
||||
// 重新获取数据
|
||||
await fetchAttendees(pagination.current, pagination.pageSize);
|
||||
} catch (error) {
|
||||
console.error('Error adding attendee:', error);
|
||||
message.error('添加失败: ' + (error.response?.data || error.message || '未知错误'));
|
||||
} finally {
|
||||
setAddLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Excel导入相关函数
|
||||
const showImportModal = () => {
|
||||
setIsImportModalVisible(true);
|
||||
};
|
||||
|
||||
const handleImportCancel = () => {
|
||||
setIsImportModalVisible(false);
|
||||
setFileList([]);
|
||||
};
|
||||
|
||||
const handleFileUpload = async () => {
|
||||
if (fileList.length === 0) {
|
||||
message.error('请选择要上传的Excel文件');
|
||||
return;
|
||||
}
|
||||
|
||||
const file = fileList[0];
|
||||
console.log('准备上传文件:', file);
|
||||
console.log('文件对象:', file.originFileObj || file);
|
||||
|
||||
setImportLoading(true);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
// 确保使用正确的文件对象
|
||||
const fileToUpload = file.originFileObj || file;
|
||||
formData.append('file', fileToUpload);
|
||||
|
||||
console.log('FormData 内容:', formData.get('file'));
|
||||
|
||||
const response = await axios.post('http://localhost:3001/api/attendees/import', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
|
||||
console.log('上传响应:', response.data);
|
||||
|
||||
if (response.data.success) {
|
||||
message.success(response.data.message);
|
||||
if (response.data.errors && response.data.errors.length > 0) {
|
||||
// 显示详细错误信息
|
||||
Modal.info({
|
||||
title: '导入结果详情',
|
||||
content: (
|
||||
<div>
|
||||
<p>{response.data.message}</p>
|
||||
{response.data.errors.length > 0 && (
|
||||
<div>
|
||||
<p>错误详情:</p>
|
||||
<ul>
|
||||
{response.data.errors.map((error, index) => (
|
||||
<li key={index}>{error}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
width: 600,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
message.error(response.data.message || '导入失败');
|
||||
}
|
||||
|
||||
setIsImportModalVisible(false);
|
||||
setFileList([]);
|
||||
// 重新获取数据
|
||||
await fetchAttendees(pagination.current, pagination.pageSize);
|
||||
} catch (error) {
|
||||
console.error('Error importing attendees:', error);
|
||||
console.error('Error details:', {
|
||||
message: error.message,
|
||||
response: error.response,
|
||||
request: error.request
|
||||
});
|
||||
|
||||
// 更详细的错误处理
|
||||
let errorMessage = '导入失败';
|
||||
if (error.response) {
|
||||
// 服务器返回了错误响应
|
||||
console.log('服务器错误响应:', error.response.status, error.response.data);
|
||||
if (error.response.status === 400) {
|
||||
if (error.response.data && typeof error.response.data === 'string' &&
|
||||
error.response.data.includes('boundary')) {
|
||||
errorMessage = '请求格式错误:请确保选择了正确的Excel文件,并且文件没有损坏';
|
||||
} else {
|
||||
errorMessage = '请求格式错误,请确保选择了正确的Excel文件';
|
||||
}
|
||||
} else if (error.response.data) {
|
||||
if (typeof error.response.data === 'string') {
|
||||
errorMessage = `导入失败: ${error.response.data}`;
|
||||
} else if (error.response.data.message) {
|
||||
errorMessage = `导入失败: ${error.response.data.message}`;
|
||||
}
|
||||
}
|
||||
} else if (error.request) {
|
||||
errorMessage = '网络错误,请检查服务器连接';
|
||||
} else {
|
||||
errorMessage = `导入失败: ${error.message}`;
|
||||
}
|
||||
|
||||
message.error(errorMessage);
|
||||
} finally {
|
||||
setImportLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const downloadTemplate = () => {
|
||||
// 创建Excel模板
|
||||
const templateData = [
|
||||
['姓名', '邮箱', '手机号', '公司', '职位'],
|
||||
['张三', 'zhangsan@example.com', '13800138000', '示例公司', '产品经理'],
|
||||
['李四', 'lisi@example.com', '13800138001', '示例公司', '开发工程师'],
|
||||
];
|
||||
|
||||
const ws = XLSX.utils.aoa_to_sheet(templateData);
|
||||
const wb = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(wb, ws, '参会者模板');
|
||||
XLSX.writeFile(wb, '参会者导入模板.xlsx');
|
||||
message.success('模板下载成功');
|
||||
};
|
||||
|
||||
// 导出Excel功能
|
||||
const handleExportExcel = async () => {
|
||||
setExportLoading(true);
|
||||
try {
|
||||
// 构建查询参数
|
||||
const params = new URLSearchParams();
|
||||
if (searchText) {
|
||||
params.append('name', searchText);
|
||||
}
|
||||
|
||||
const response = await axios.get(`/api/attendees/export?${params.toString()}`, {
|
||||
responseType: 'blob', // 重要:设置响应类型为blob
|
||||
});
|
||||
|
||||
// 创建下载链接
|
||||
const blob = new Blob([response.data], {
|
||||
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
});
|
||||
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
|
||||
// 从响应头获取文件名,如果没有则使用默认名称
|
||||
const contentDisposition = response.headers['content-disposition'];
|
||||
let filename = '参会者列表.xlsx';
|
||||
if (contentDisposition) {
|
||||
const filenameMatch = contentDisposition.match(/filename="(.+)"/);
|
||||
if (filenameMatch) {
|
||||
filename = filenameMatch[1];
|
||||
}
|
||||
}
|
||||
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
|
||||
// 清理
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
||||
message.success('导出成功');
|
||||
} catch (error) {
|
||||
console.error('导出失败:', error);
|
||||
let errorMessage = '导出失败';
|
||||
if (error.response) {
|
||||
if (error.response.data instanceof Blob) {
|
||||
// 如果错误响应是blob,尝试读取文本内容
|
||||
const text = await error.response.data.text();
|
||||
errorMessage = `导出失败: ${text}`;
|
||||
} else if (error.response.data && error.response.data.message) {
|
||||
errorMessage = `导出失败: ${error.response.data.message}`;
|
||||
}
|
||||
} else if (error.request) {
|
||||
errorMessage = '网络错误,请检查服务器连接';
|
||||
} else {
|
||||
errorMessage = `导出失败: ${error.message}`;
|
||||
}
|
||||
message.error(errorMessage);
|
||||
} finally {
|
||||
setExportLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const uploadProps = {
|
||||
fileList,
|
||||
beforeUpload: (file) => {
|
||||
console.log('选择的文件:', file);
|
||||
console.log('文件类型:', file.type);
|
||||
console.log('文件名:', file.name);
|
||||
|
||||
// 检查文件扩展名
|
||||
const fileName = file.name.toLowerCase();
|
||||
const isExcelByExtension = fileName.endsWith('.xlsx') || fileName.endsWith('.xls');
|
||||
|
||||
// 检查MIME类型
|
||||
const isExcelByType = file.type === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' ||
|
||||
file.type === 'application/vnd.ms-excel' ||
|
||||
file.type === 'application/excel' ||
|
||||
file.type === 'application/x-excel';
|
||||
|
||||
if (!isExcelByExtension && !isExcelByType) {
|
||||
message.error(`文件格式不正确。请选择Excel文件(.xlsx或.xls)。当前文件类型: ${file.type}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查文件大小(限制为10MB)
|
||||
const isLt10M = file.size / 1024 / 1024 < 10;
|
||||
if (!isLt10M) {
|
||||
message.error('文件大小不能超过10MB');
|
||||
return false;
|
||||
}
|
||||
|
||||
setFileList([file]);
|
||||
message.success(`已选择文件: ${file.name}`);
|
||||
return false; // 阻止自动上传
|
||||
},
|
||||
onRemove: () => {
|
||||
setFileList([]);
|
||||
message.info('已移除文件');
|
||||
},
|
||||
maxCount: 1,
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchAttendees();
|
||||
}, []);
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
},
|
||||
{
|
||||
title: '姓名',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
},
|
||||
{
|
||||
title: '手机号',
|
||||
dataIndex: 'phone',
|
||||
key: 'phone',
|
||||
},
|
||||
{
|
||||
title: '公司',
|
||||
dataIndex: 'company',
|
||||
key: 'company',
|
||||
},
|
||||
{
|
||||
title: '职位',
|
||||
dataIndex: 'position',
|
||||
key: 'position',
|
||||
},
|
||||
{
|
||||
title: '签到状态',
|
||||
dataIndex: 'checked_in',
|
||||
key: 'checked_in',
|
||||
render: (checked_in) => checked_in ? '已签到' : '未签到',
|
||||
},
|
||||
{
|
||||
title: '邮箱',
|
||||
dataIndex: 'email',
|
||||
key: 'email',
|
||||
},
|
||||
{
|
||||
title: '注册时间',
|
||||
dataIndex: 'created_at',
|
||||
key: 'created_at',
|
||||
render: (created_at) => new Date(created_at).toLocaleString(),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Title level={4}>参会者列表</Title>
|
||||
<Space style={{ marginBottom: 16 }}>
|
||||
<Input
|
||||
placeholder="搜索参会者..."
|
||||
prefix={<SearchOutlined />}
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
style={{ width: 300 }}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={showAddModal}
|
||||
>
|
||||
新增参会者
|
||||
</Button>
|
||||
<Button
|
||||
icon={<UploadOutlined />}
|
||||
onClick={showImportModal}
|
||||
>
|
||||
导入Excel
|
||||
</Button>
|
||||
<Button
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={handleExportExcel}
|
||||
loading={exportLoading}
|
||||
>
|
||||
导出Excel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={downloadTemplate}
|
||||
>
|
||||
下载模板
|
||||
</Button>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={() => fetchAttendees(pagination.current, pagination.pageSize)}
|
||||
loading={loading}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="确定要删除选中的参会者吗?"
|
||||
onConfirm={handleBatchDelete}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
disabled={selectedRowKeys.length === 0}
|
||||
>
|
||||
批量删除 ({selectedRowKeys.length})
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
<Table
|
||||
dataSource={filteredAttendees}
|
||||
columns={columns}
|
||||
loading={loading}
|
||||
rowKey="id"
|
||||
rowSelection={{
|
||||
selectedRowKeys,
|
||||
onChange: setSelectedRowKeys,
|
||||
}}
|
||||
pagination={{
|
||||
...pagination,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
showTotal: (total, range) => `第 ${range[0]}-${range[1]} 条,共 ${total} 条`,
|
||||
}}
|
||||
onChange={handleTableChange}
|
||||
/>
|
||||
|
||||
{/* 新增参会者弹窗 */}
|
||||
<Modal
|
||||
title="新增参会者"
|
||||
open={isModalVisible}
|
||||
onCancel={handleAddCancel}
|
||||
footer={null}
|
||||
width={600}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={handleAddSubmit}
|
||||
autoComplete="off"
|
||||
>
|
||||
<Form.Item
|
||||
label="姓名"
|
||||
name="name"
|
||||
rules={[{ required: true, message: '请输入姓名!' }]}
|
||||
>
|
||||
<Input placeholder="请输入姓名" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="手机号"
|
||||
name="phone"
|
||||
rules={[{ required: true, message: '请输入手机号!' }]}
|
||||
>
|
||||
<Input placeholder="请输入手机号" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="公司"
|
||||
name="company"
|
||||
>
|
||||
<Input placeholder="请输入公司名称" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="职位"
|
||||
name="position"
|
||||
>
|
||||
<Input placeholder="请输入职位" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="邮箱"
|
||||
name="email"
|
||||
rules={[
|
||||
{ type: 'email', message: '请输入有效的邮箱地址!' }
|
||||
]}
|
||||
>
|
||||
<Input placeholder="请输入邮箱" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit" loading={addLoading}>
|
||||
添加参会者
|
||||
</Button>
|
||||
<Button onClick={handleAddCancel}>
|
||||
取消
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* Excel导入Modal */}
|
||||
<Modal
|
||||
title="导入参会者Excel文件"
|
||||
open={isImportModalVisible}
|
||||
onOk={handleFileUpload}
|
||||
onCancel={handleImportCancel}
|
||||
confirmLoading={importLoading}
|
||||
okText="导入"
|
||||
cancelText="取消"
|
||||
width={600}
|
||||
>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<p>请选择要导入的Excel文件,文件格式要求:</p>
|
||||
<ul>
|
||||
<li>第一行为表头:姓名、邮箱、手机号、公司、职位</li>
|
||||
<li>姓名和邮箱为必填字段</li>
|
||||
<li>邮箱格式必须正确</li>
|
||||
<li>支持.xlsx和.xls格式</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<Upload {...uploadProps}>
|
||||
<Button icon={<UploadOutlined />}>选择Excel文件</Button>
|
||||
</Upload>
|
||||
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<Button type="link" onClick={downloadTemplate}>
|
||||
下载Excel模板
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AttendeeList;
|
||||
85
frontend/src/components/AttendeeRegistration.jsx
Normal file
85
frontend/src/components/AttendeeRegistration.jsx
Normal file
@ -0,0 +1,85 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Form, Input, Button, message, Card, Typography } from 'antd';
|
||||
import axios from 'axios';
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
const AttendeeRegistration = () => {
|
||||
const [form] = Form.useForm();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const onFinish = async (values) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
console.log('Registering attendee with values:', values);
|
||||
const response = await axios.post('/api/attendees', values);
|
||||
console.log('Registration response:', response.data);
|
||||
message.success('参会者注册成功!');
|
||||
form.resetFields();
|
||||
} catch (error) {
|
||||
console.error('Error registering attendee:', error);
|
||||
message.error('注册失败: ' + (error.response?.data || error.message || '未知错误'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Title level={4}>参会者注册</Title>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={onFinish}
|
||||
autoComplete="off"
|
||||
>
|
||||
<Form.Item
|
||||
label="姓名"
|
||||
name="name"
|
||||
rules={[{ required: true, message: '请输入姓名!' }]}
|
||||
>
|
||||
<Input placeholder="请输入姓名" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="邮箱"
|
||||
name="email"
|
||||
rules={[
|
||||
{ type: 'email', message: '请输入有效的邮箱地址!' }
|
||||
]}
|
||||
>
|
||||
<Input placeholder="请输入邮箱" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="手机号"
|
||||
name="phone"
|
||||
>
|
||||
<Input placeholder="请输入手机号" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="公司"
|
||||
name="company"
|
||||
>
|
||||
<Input placeholder="请输入公司名称" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="职位"
|
||||
name="position"
|
||||
>
|
||||
<Input placeholder="请输入职位" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" loading={loading}>
|
||||
注册参会者
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default AttendeeRegistration;
|
||||
327
frontend/src/components/CheckinPage.jsx
Normal file
327
frontend/src/components/CheckinPage.jsx
Normal file
@ -0,0 +1,327 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Table, Button, message, Typography, Input, Space, Popconfirm } from 'antd';
|
||||
import { SearchOutlined, CheckCircleOutlined, ReloadOutlined, DeleteOutlined, UndoOutlined, DownloadOutlined } from '@ant-design/icons';
|
||||
import axios from 'axios';
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
const CheckinPage = () => {
|
||||
const [attendees, setAttendees] = useState([]);
|
||||
const [filteredAttendees, setFilteredAttendees] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState([]);
|
||||
// Excel导出相关状态
|
||||
const [exportLoading, setExportLoading] = useState(false);
|
||||
const [pagination, setPagination] = useState({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
});
|
||||
|
||||
const fetchAttendees = async (page = 1, pageSize = 10) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
console.log(`Fetching attendees from /api/attendees?page=${page}&page_size=${pageSize}`);
|
||||
const response = await axios.get(`/api/attendees?page=${page}&page_size=${pageSize}`);
|
||||
console.log('Received response:', response.data);
|
||||
|
||||
setAttendees(response.data.data);
|
||||
setFilteredAttendees(response.data.data);
|
||||
setPagination({
|
||||
current: response.data.page,
|
||||
pageSize: response.data.page_size,
|
||||
total: response.data.total,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching attendees:', error);
|
||||
message.error('获取参会者列表失败: ' + (error.response?.data || error.message || '未知错误'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBatchDelete = async () => {
|
||||
if (selectedRowKeys.length === 0) {
|
||||
message.warning('请选择要删除的参会者');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.delete('/api/attendees/batch-delete', {
|
||||
data: { ids: selectedRowKeys }
|
||||
});
|
||||
|
||||
message.success(response.data.message);
|
||||
setSelectedRowKeys([]);
|
||||
fetchAttendees(pagination.current, pagination.pageSize);
|
||||
} catch (error) {
|
||||
console.error('Error deleting attendees:', error);
|
||||
message.error('批量删除失败: ' + (error.response?.data || error.message || '未知错误'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleBatchResetCheckin = async () => {
|
||||
if (selectedRowKeys.length === 0) {
|
||||
message.warning('请选择要重置签到记录的参会者');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.post('/api/attendees/batch-reset-checkin', {
|
||||
ids: selectedRowKeys
|
||||
});
|
||||
|
||||
message.success(response.data.message);
|
||||
setSelectedRowKeys([]);
|
||||
fetchAttendees(pagination.current, pagination.pageSize);
|
||||
} catch (error) {
|
||||
console.error('Error resetting checkin:', error);
|
||||
message.error('批量重置签到记录失败: ' + (error.response?.data || error.message || '未知错误'));
|
||||
}
|
||||
};
|
||||
|
||||
// 导出Excel功能
|
||||
const handleExportExcel = async () => {
|
||||
setExportLoading(true);
|
||||
try {
|
||||
// 构建查询参数
|
||||
const params = new URLSearchParams();
|
||||
if (searchText) {
|
||||
params.append('name', searchText);
|
||||
}
|
||||
|
||||
const response = await axios.get(`/api/attendees/export?${params.toString()}`, {
|
||||
responseType: 'blob', // 重要:设置响应类型为blob
|
||||
});
|
||||
|
||||
// 创建下载链接
|
||||
const blob = new Blob([response.data], {
|
||||
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
});
|
||||
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
|
||||
// 从响应头获取文件名,如果没有则使用默认名称
|
||||
const contentDisposition = response.headers['content-disposition'];
|
||||
let filename = '签到管理列表.xlsx';
|
||||
if (contentDisposition) {
|
||||
const filenameMatch = contentDisposition.match(/filename="(.+)"/);
|
||||
if (filenameMatch) {
|
||||
filename = filenameMatch[1];
|
||||
}
|
||||
}
|
||||
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
|
||||
// 清理
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
||||
message.success('导出成功');
|
||||
} catch (error) {
|
||||
console.error('导出失败:', error);
|
||||
let errorMessage = '导出失败';
|
||||
if (error.response) {
|
||||
if (error.response.data instanceof Blob) {
|
||||
// 如果错误响应是blob,尝试读取文本内容
|
||||
const text = await error.response.data.text();
|
||||
errorMessage = `导出失败: ${text}`;
|
||||
} else if (error.response.data && error.response.data.message) {
|
||||
errorMessage = `导出失败: ${error.response.data.message}`;
|
||||
}
|
||||
} else if (error.request) {
|
||||
errorMessage = '网络错误,请检查服务器连接';
|
||||
} else {
|
||||
errorMessage = `导出失败: ${error.message}`;
|
||||
}
|
||||
message.error(errorMessage);
|
||||
} finally {
|
||||
setExportLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTableChange = (paginationInfo) => {
|
||||
fetchAttendees(paginationInfo.current, paginationInfo.pageSize);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchAttendees();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!searchText) {
|
||||
setFilteredAttendees(attendees);
|
||||
} else {
|
||||
const filtered = attendees.filter(attendee =>
|
||||
attendee.name.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||
attendee.email.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||
(attendee.phone && attendee.phone.includes(searchText))
|
||||
);
|
||||
setFilteredAttendees(filtered);
|
||||
}
|
||||
}, [searchText, attendees]);
|
||||
|
||||
const handleCheckin = async (id) => {
|
||||
try {
|
||||
console.log(`Checking in attendee with ID: ${id}`);
|
||||
const response = await axios.post(`/api/attendees/${id}/checkin`);
|
||||
console.log('Checkin response:', response.data);
|
||||
message.success('签到成功!');
|
||||
|
||||
// 更新本地状态
|
||||
setAttendees(prev => prev.map(attendee =>
|
||||
attendee.id === id ? response.data : attendee
|
||||
));
|
||||
|
||||
setFilteredAttendees(prev => prev.map(attendee =>
|
||||
attendee.id === id ? response.data : attendee
|
||||
));
|
||||
} catch (error) {
|
||||
console.error('Error checking in attendee:', error);
|
||||
message.error('签到失败: ' + (error.response?.data || error.message || '未知错误'));
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
},
|
||||
{
|
||||
title: '姓名',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
},
|
||||
{
|
||||
title: '手机号',
|
||||
dataIndex: 'phone',
|
||||
key: 'phone',
|
||||
},
|
||||
{
|
||||
title: '公司',
|
||||
dataIndex: 'company',
|
||||
key: 'company',
|
||||
},
|
||||
{
|
||||
title: '职位',
|
||||
dataIndex: 'position',
|
||||
key: 'position',
|
||||
},
|
||||
{
|
||||
title: '签到状态',
|
||||
dataIndex: 'checked_in',
|
||||
key: 'checked_in',
|
||||
render: (checked_in) => checked_in ? '已签到' : '未签到',
|
||||
},
|
||||
{
|
||||
title: '签到时间',
|
||||
dataIndex: 'checkin_time',
|
||||
key: 'checkin_time',
|
||||
render: (checkin_time) => checkin_time ? new Date(checkin_time).toLocaleString() : '-',
|
||||
},
|
||||
{
|
||||
title: '邮箱',
|
||||
dataIndex: 'email',
|
||||
key: 'email',
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
render: (_, record) => (
|
||||
!record.checked_in ? (
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<CheckCircleOutlined />}
|
||||
onClick={() => handleCheckin(record.id)}
|
||||
>
|
||||
签到
|
||||
</Button>
|
||||
) : (
|
||||
<span>已签到</span>
|
||||
)
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Title level={4}>签到管理</Title>
|
||||
<Space style={{ marginBottom: 16 }}>
|
||||
<Input
|
||||
placeholder="搜索参会者..."
|
||||
prefix={<SearchOutlined />}
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
style={{ width: 300 }}
|
||||
/>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={() => fetchAttendees(pagination.current, pagination.pageSize)}
|
||||
loading={loading}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
<Button
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={handleExportExcel}
|
||||
loading={exportLoading}
|
||||
>
|
||||
导出Excel
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="确定要删除选中的参会者吗?"
|
||||
onConfirm={handleBatchDelete}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
disabled={selectedRowKeys.length === 0}
|
||||
>
|
||||
批量删除 ({selectedRowKeys.length})
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
<Popconfirm
|
||||
title="确定要重置选中参会者的签到记录吗?参会者信息将保留,但签到状态将重置为未签到。"
|
||||
onConfirm={handleBatchResetCheckin}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button
|
||||
type="default"
|
||||
icon={<UndoOutlined />}
|
||||
disabled={selectedRowKeys.length === 0}
|
||||
>
|
||||
重置签到记录 ({selectedRowKeys.length})
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
<Table
|
||||
dataSource={filteredAttendees}
|
||||
columns={columns}
|
||||
loading={loading}
|
||||
rowKey="id"
|
||||
rowSelection={{
|
||||
selectedRowKeys,
|
||||
onChange: setSelectedRowKeys,
|
||||
}}
|
||||
pagination={{
|
||||
...pagination,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
showTotal: (total, range) => `第 ${range[0]}-${range[1]} 条,共 ${total} 条`,
|
||||
}}
|
||||
onChange={handleTableChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CheckinPage;
|
||||
109
frontend/src/components/Login.jsx
Normal file
109
frontend/src/components/Login.jsx
Normal file
@ -0,0 +1,109 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Form, Input, Button, Card, Typography, message } from 'antd';
|
||||
import { UserOutlined, LockOutlined } from '@ant-design/icons';
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
const Login = ({ onLogin }) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const onFinish = async (values) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// 调用后端登录API
|
||||
const response = await fetch('http://localhost:3001/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
username: values.username,
|
||||
password: values.password,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
// 保存登录状态和令牌
|
||||
localStorage.setItem('isLoggedIn', 'true');
|
||||
localStorage.setItem('loginTime', Date.now().toString());
|
||||
localStorage.setItem('authToken', data.token);
|
||||
localStorage.setItem('userInfo', JSON.stringify(data.user));
|
||||
|
||||
message.success(data.message || '登录成功!');
|
||||
onLogin(true);
|
||||
} else {
|
||||
message.error(data.message || '登录失败!');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('登录错误:', error);
|
||||
message.error('网络错误,请稍后重试!');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
minHeight: '100vh',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)'
|
||||
}}>
|
||||
<Card style={{ width: 400, boxShadow: '0 4px 12px rgba(0,0,0,0.15)' }}>
|
||||
<div style={{ textAlign: 'center', marginBottom: 24 }}>
|
||||
<Title level={2} style={{ color: '#1890ff', marginBottom: 8 }}>
|
||||
会议签到系统
|
||||
</Title>
|
||||
<p style={{ color: '#666', margin: 0 }}>管理员登录</p>
|
||||
</div>
|
||||
|
||||
<Form
|
||||
name="login"
|
||||
onFinish={onFinish}
|
||||
autoComplete="off"
|
||||
size="large"
|
||||
>
|
||||
<Form.Item
|
||||
name="username"
|
||||
rules={[{ required: true, message: '请输入用户名!' }]}
|
||||
>
|
||||
<Input
|
||||
prefix={<UserOutlined />}
|
||||
placeholder="用户名"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="password"
|
||||
rules={[{ required: true, message: '请输入密码!' }]}
|
||||
>
|
||||
<Input.Password
|
||||
prefix={<LockOutlined />}
|
||||
placeholder="密码"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={loading}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
登录
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<div style={{ textAlign: 'center', color: '#999', fontSize: '12px' }}>
|
||||
<p>默认账号:admin / admin123</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Login;
|
||||
16
frontend/src/index.css
Normal file
16
frontend/src/index.css
Normal file
@ -0,0 +1,16 @@
|
||||
@import 'antd/dist/reset.css';
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
10
frontend/src/main.jsx
Normal file
10
frontend/src/main.jsx
Normal file
@ -0,0 +1,10 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
28
frontend/vite.config.js
Normal file
28
frontend/vite.config.js
Normal file
@ -0,0 +1,28 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://127.0.0.1:3001',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
configure: (proxy, options) => {
|
||||
proxy.on('error', (err, req, res) => {
|
||||
console.log('Proxy error:', err);
|
||||
});
|
||||
proxy.on('proxyReq', (proxyReq, req, res) => {
|
||||
console.log('Proxy request:', req.method, req.url);
|
||||
});
|
||||
proxy.on('proxyRes', (proxyRes, req, res) => {
|
||||
console.log('Proxy response:', proxyRes.statusCode, req.url);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
15
migrations/001_create_attendees_table.sql
Normal file
15
migrations/001_create_attendees_table.sql
Normal file
@ -0,0 +1,15 @@
|
||||
-- 创建参会者表
|
||||
CREATE TABLE IF NOT EXISTS `attendees` (
|
||||
`id` INT AUTO_INCREMENT PRIMARY KEY,
|
||||
`name` VARCHAR(100) NOT NULL,
|
||||
`email` VARCHAR(100) NULL,
|
||||
`phone` VARCHAR(20) NULL,
|
||||
`company` VARCHAR(100) NULL,
|
||||
`position` VARCHAR(100) NULL,
|
||||
`checked_in` BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
`checkin_time` TIMESTAMP NULL,
|
||||
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX `idx_email` (`email`),
|
||||
INDEX `idx_checked_in` (`checked_in`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
19
migrations/002_create_users_table.sql
Normal file
19
migrations/002_create_users_table.sql
Normal file
@ -0,0 +1,19 @@
|
||||
-- 创建用户表
|
||||
CREATE TABLE users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username VARCHAR(50) NOT NULL UNIQUE,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
email VARCHAR(100),
|
||||
is_active BOOLEAN NOT NULL DEFAULT 1,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 创建索引
|
||||
CREATE INDEX idx_users_username ON users(username);
|
||||
CREATE INDEX idx_users_email ON users(email);
|
||||
|
||||
-- 插入默认管理员用户 (密码: admin123)
|
||||
-- 这里使用bcrypt哈希后的密码
|
||||
INSERT INTO users (username, password_hash, email) VALUES
|
||||
('admin', '$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewdBPj/RK.PJ/..2', 'admin@example.com');
|
||||
19
migrations/002_create_users_table_mysql.sql
Normal file
19
migrations/002_create_users_table_mysql.sql
Normal file
@ -0,0 +1,19 @@
|
||||
-- 创建用户表 (MySQL版本)
|
||||
CREATE TABLE users (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
username VARCHAR(50) NOT NULL UNIQUE,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
email VARCHAR(100),
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 创建索引
|
||||
CREATE INDEX idx_users_username ON users(username);
|
||||
CREATE INDEX idx_users_email ON users(email);
|
||||
|
||||
-- 插入默认管理员用户 (密码: admin123)
|
||||
-- 这里使用bcrypt哈希后的密码
|
||||
INSERT INTO users (username, password_hash, email) VALUES
|
||||
('admin', '$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewdBPj/RK.PJ/..2', 'admin@example.com');
|
||||
19
migrations/003_create_config_table.sql
Normal file
19
migrations/003_create_config_table.sql
Normal file
@ -0,0 +1,19 @@
|
||||
-- 创建配置表
|
||||
CREATE TABLE IF NOT EXISTS config (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
config_key VARCHAR(100) NOT NULL UNIQUE,
|
||||
config_value TEXT NOT NULL,
|
||||
description VARCHAR(255),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 插入默认配置数据
|
||||
INSERT INTO config (config_key, config_value, description) VALUES
|
||||
('main_title', '会议签到', '首屏主标题'),
|
||||
('subtitle', '请填写以下信息完成签到', '首屏副标题'),
|
||||
('success_title', '签到成功', '签到成功页面标题'),
|
||||
('success_message', '欢迎参加本次会议,祝您会议愉快!', '签到成功消息'),
|
||||
('already_checked_title', '您已签到', '重复签到页面标题'),
|
||||
('already_checked_message', '您之前已经完成签到,无需重复签到。祝您会议愉快!', '重复签到消息'),
|
||||
('success_main_title', '签到成功', '签到成功后的主标题');
|
||||
BIN
qiandao.db
Normal file
BIN
qiandao.db
Normal file
Binary file not shown.
102
scripts/build.sh
Normal file
102
scripts/build.sh
Normal file
@ -0,0 +1,102 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "$0")"/.. && pwd)"
|
||||
FRONTEND_DIR="$ROOT_DIR/frontend"
|
||||
BACKEND_DIR="$ROOT_DIR"
|
||||
RELEASE_DIR="$ROOT_DIR/release"
|
||||
BUNDLE_DIR="$RELEASE_DIR/qiandao_bundle"
|
||||
TS="$(date +%Y%m%d_%H%M%S)"
|
||||
ARCHIVE_NAME="qiandao_${TS}.tar.gz"
|
||||
|
||||
echo "==> 清理并准备发布目录"
|
||||
rm -rf "$BUNDLE_DIR"
|
||||
mkdir -p "$BUNDLE_DIR"
|
||||
|
||||
echo "==> 构建前端 (vite build)"
|
||||
pushd "$FRONTEND_DIR" >/dev/null
|
||||
npm ci
|
||||
npm run build
|
||||
popd >/dev/null
|
||||
|
||||
echo "==> 构建后端 (cargo build --release)"
|
||||
pushd "$BACKEND_DIR" >/dev/null
|
||||
cargo build --release
|
||||
popd >/dev/null
|
||||
|
||||
echo "==> 收集构建产物"
|
||||
# 后端二进制
|
||||
mkdir -p "$BUNDLE_DIR/backend"
|
||||
cp "$BACKEND_DIR/target/release/qiandao" "$BUNDLE_DIR/backend/"
|
||||
# 前端静态文件
|
||||
mkdir -p "$BUNDLE_DIR/frontend"
|
||||
cp -r "$FRONTEND_DIR/dist" "$BUNDLE_DIR/frontend/"
|
||||
|
||||
# 环境变量示例(如存在,优先示例文件,否则复制现有 .env)
|
||||
if [[ -f "$ROOT_DIR/.env.example" ]]; then
|
||||
cp "$ROOT_DIR/.env.example" "$BUNDLE_DIR/backend/.env.example"
|
||||
elif [[ -f "$ROOT_DIR/.env" ]]; then
|
||||
cp "$ROOT_DIR/.env" "$BUNDLE_DIR/backend/.env"
|
||||
fi
|
||||
|
||||
# 生成部署说明
|
||||
cat > "$BUNDLE_DIR/README_DEPLOY.md" << 'EOF'
|
||||
# 部署说明(前后端打包)
|
||||
|
||||
## 内容结构
|
||||
- backend/: 后端二进制与环境变量
|
||||
- qiandao: 后端二进制
|
||||
- .env 或 .env.example: 环境变量文件
|
||||
- frontend/: 前端静态资源
|
||||
- dist/: 生产构建后的静态文件
|
||||
|
||||
## 运行后端
|
||||
```bash
|
||||
cd backend
|
||||
chmod +x ./qiandao
|
||||
./qiandao
|
||||
```
|
||||
后端默认监听端口:`3001`
|
||||
|
||||
## 运行前端
|
||||
将 `frontend/dist` 作为静态网站托管(例如 Nginx):
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name your-domain;
|
||||
root /var/www/qiandao/frontend/dist;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# 反向代理后端 API
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:3001/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 环境变量
|
||||
将 `.env` 放在 `backend/` 目录或系统环境中。确保配置正确的 MySQL、Redis、JWT 等。
|
||||
|
||||
## 注意事项
|
||||
- 前端构建后产物在 `frontend/dist`,直接静态托管即可。
|
||||
- 后端为单个二进制,无需额外依赖(运行期需 MySQL/Redis 服务)。
|
||||
- 若需使用 Docker,请联系生成生产用 Dockerfile 与 compose。
|
||||
EOF
|
||||
|
||||
mkdir -p "$RELEASE_DIR"
|
||||
|
||||
echo "==> 生成压缩包: $ARCHIVE_NAME"
|
||||
pushd "$RELEASE_DIR" >/dev/null
|
||||
tar -czf "$ARCHIVE_NAME" "$(basename "$BUNDLE_DIR")"
|
||||
popd >/dev/null
|
||||
|
||||
echo "==> 打包完成"
|
||||
echo "包路径: $RELEASE_DIR/$ARCHIVE_NAME"
|
||||
echo "内容目录: $BUNDLE_DIR"
|
||||
66
src/auth.rs
Normal file
66
src/auth.rs
Normal file
@ -0,0 +1,66 @@
|
||||
use bcrypt::{hash, verify, DEFAULT_COST};
|
||||
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::env;
|
||||
|
||||
// JWT Claims 结构体
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Claims {
|
||||
pub sub: String, // 用户名
|
||||
pub user_id: i32,
|
||||
pub exp: usize, // 过期时间
|
||||
}
|
||||
|
||||
// 密码加密
|
||||
pub fn hash_password(password: &str) -> Result<String, bcrypt::BcryptError> {
|
||||
hash(password, DEFAULT_COST)
|
||||
}
|
||||
|
||||
// 验证密码
|
||||
pub fn verify_password(password: &str, hash: &str) -> Result<bool, bcrypt::BcryptError> {
|
||||
verify(password, hash)
|
||||
}
|
||||
|
||||
// 生成JWT令牌
|
||||
pub fn generate_token(user_id: i32, username: &str) -> Result<String, jsonwebtoken::errors::Error> {
|
||||
let secret = env::var("JWT_SECRET").unwrap_or_else(|_| "default_secret_key".to_string());
|
||||
|
||||
let expiration = chrono::Utc::now()
|
||||
.checked_add_signed(chrono::Duration::hours(24))
|
||||
.expect("valid timestamp")
|
||||
.timestamp() as usize;
|
||||
|
||||
let claims = Claims {
|
||||
sub: username.to_string(),
|
||||
user_id,
|
||||
exp: expiration,
|
||||
};
|
||||
|
||||
encode(
|
||||
&Header::default(),
|
||||
&claims,
|
||||
&EncodingKey::from_secret(secret.as_ref()),
|
||||
)
|
||||
}
|
||||
|
||||
// 验证JWT令牌
|
||||
pub fn verify_token(token: &str) -> Result<Claims, jsonwebtoken::errors::Error> {
|
||||
let secret = env::var("JWT_SECRET").unwrap_or_else(|_| "default_secret_key".to_string());
|
||||
|
||||
let token_data = decode::<Claims>(
|
||||
token,
|
||||
&DecodingKey::from_secret(secret.as_ref()),
|
||||
&Validation::default(),
|
||||
)?;
|
||||
|
||||
Ok(token_data.claims)
|
||||
}
|
||||
|
||||
// 从Authorization头部提取令牌
|
||||
pub fn extract_token_from_header(auth_header: &str) -> Option<&str> {
|
||||
if auth_header.starts_with("Bearer ") {
|
||||
Some(&auth_header[7..])
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
26
src/bin/debug_password.rs
Normal file
26
src/bin/debug_password.rs
Normal file
@ -0,0 +1,26 @@
|
||||
use bcrypt::{hash, verify, DEFAULT_COST};
|
||||
|
||||
fn main() {
|
||||
let password = "admin123";
|
||||
|
||||
// 生成新的哈希
|
||||
match hash(password, DEFAULT_COST) {
|
||||
Ok(new_hash) => {
|
||||
println!("新生成的哈希: {}", new_hash);
|
||||
|
||||
// 验证新哈希
|
||||
match verify(password, &new_hash) {
|
||||
Ok(is_valid) => println!("新哈希验证结果: {}", is_valid),
|
||||
Err(e) => println!("新哈希验证错误: {}", e),
|
||||
}
|
||||
},
|
||||
Err(e) => println!("生成哈希错误: {}", e),
|
||||
}
|
||||
|
||||
// 测试数据库中的哈希
|
||||
let db_hash = "$2b$12$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi";
|
||||
match verify(password, db_hash) {
|
||||
Ok(is_valid) => println!("数据库哈希验证结果: {}", is_valid),
|
||||
Err(e) => println!("数据库哈希验证错误: {}", e),
|
||||
}
|
||||
}
|
||||
102
src/database.rs
Normal file
102
src/database.rs
Normal file
@ -0,0 +1,102 @@
|
||||
use sea_orm::*;
|
||||
use std::env;
|
||||
|
||||
pub async fn init() -> DatabaseConnection {
|
||||
let mut db_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set");
|
||||
|
||||
// 确保数据库连接URL包含字符集参数
|
||||
if !db_url.contains("charset") {
|
||||
if db_url.contains("?") {
|
||||
db_url.push_str("&charset=utf8mb4");
|
||||
} else {
|
||||
db_url.push_str("?charset=utf8mb4");
|
||||
}
|
||||
}
|
||||
|
||||
let mut opt = ConnectOptions::new(db_url);
|
||||
opt.max_connections(100)
|
||||
.min_connections(5)
|
||||
.acquire_timeout(std::time::Duration::from_secs(8))
|
||||
.idle_timeout(std::time::Duration::from_secs(8))
|
||||
.max_lifetime(std::time::Duration::from_secs(8))
|
||||
.sqlx_logging(true);
|
||||
|
||||
// 尝试连接数据库,如果失败则使用空连接
|
||||
let db = match Database::connect(opt).await {
|
||||
Ok(db) => {
|
||||
tracing::info!("Database connected successfully");
|
||||
// 设置数据库字符集
|
||||
set_charset(&db).await;
|
||||
// Run migrations
|
||||
run_migrations(&db).await;
|
||||
db
|
||||
},
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to connect to database: {}", e);
|
||||
// 创建一个模拟的数据库连接用于测试
|
||||
panic!("Database connection failed: {}", e);
|
||||
}
|
||||
};
|
||||
|
||||
db
|
||||
}
|
||||
|
||||
async fn set_charset(db: &DatabaseConnection) {
|
||||
// 设置连接字符集为UTF-8
|
||||
let charset_sqls = vec![
|
||||
"SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci",
|
||||
"SET character_set_client = utf8mb4",
|
||||
"SET character_set_connection = utf8mb4",
|
||||
"SET character_set_results = utf8mb4",
|
||||
"SET collation_connection = utf8mb4_unicode_ci"
|
||||
];
|
||||
|
||||
for sql in charset_sqls {
|
||||
match db.execute(sea_orm::Statement::from_string(
|
||||
sea_orm::DatabaseBackend::MySql,
|
||||
sql.to_owned()
|
||||
)).await {
|
||||
Ok(_) => tracing::info!("Executed charset SQL: {}", sql),
|
||||
Err(e) => tracing::error!("Failed to execute charset SQL {}: {}", sql, e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_migrations(db: &DatabaseConnection) {
|
||||
// 首先移除邮箱的唯一约束(如果存在)
|
||||
let drop_unique_sql = r#"
|
||||
ALTER TABLE `attendees` DROP INDEX `email`;
|
||||
"#;
|
||||
|
||||
// 尝试删除唯一约束,如果不存在则忽略错误
|
||||
let _ = db.execute(sea_orm::Statement::from_string(
|
||||
sea_orm::DatabaseBackend::MySql,
|
||||
drop_unique_sql.to_owned()
|
||||
)).await;
|
||||
|
||||
let sql = r#"
|
||||
CREATE TABLE IF NOT EXISTS `attendees` (
|
||||
`id` INT AUTO_INCREMENT PRIMARY KEY,
|
||||
`name` VARCHAR(100) NOT NULL,
|
||||
`email` VARCHAR(100) NOT NULL,
|
||||
`phone` VARCHAR(20) NULL,
|
||||
`company` VARCHAR(100) NULL,
|
||||
`position` VARCHAR(100) NULL,
|
||||
`checked_in` BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
`checkin_time` TIMESTAMP NULL,
|
||||
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX `idx_email` (`email`),
|
||||
INDEX `idx_checked_in` (`checked_in`),
|
||||
INDEX `idx_name_phone` (`name`, `phone`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
"#;
|
||||
|
||||
match db.execute(sea_orm::Statement::from_string(
|
||||
sea_orm::DatabaseBackend::MySql,
|
||||
sql.to_owned()
|
||||
)).await {
|
||||
Ok(_) => tracing::info!("Migrations executed successfully"),
|
||||
Err(e) => tracing::error!("Failed to execute migrations: {}", e),
|
||||
}
|
||||
}
|
||||
4
src/lib.rs
Normal file
4
src/lib.rs
Normal file
@ -0,0 +1,4 @@
|
||||
pub mod auth;
|
||||
pub mod database;
|
||||
pub mod models;
|
||||
pub mod routes;
|
||||
58
src/main.rs
Normal file
58
src/main.rs
Normal file
@ -0,0 +1,58 @@
|
||||
use axum::{
|
||||
routing::{get, post, delete, put},
|
||||
Router,
|
||||
};
|
||||
use tower_http::cors::{CorsLayer, Any};
|
||||
use std::net::SocketAddr;
|
||||
use tracing_subscriber;
|
||||
use dotenv::dotenv;
|
||||
use std::env;
|
||||
|
||||
mod auth;
|
||||
mod database;
|
||||
mod models;
|
||||
mod routes;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
// Load environment variables
|
||||
dotenv().ok();
|
||||
|
||||
// Initialize tracing
|
||||
tracing_subscriber::fmt::init();
|
||||
|
||||
// Initialize database
|
||||
let db = database::init().await;
|
||||
|
||||
// Build the application with routes
|
||||
let app = Router::new()
|
||||
.route("/", get(root))
|
||||
.route("/api/attendees", post(routes::create_attendee))
|
||||
.route("/api/attendees", get(routes::get_attendees))
|
||||
.route("/api/attendees/:id", get(routes::get_attendee))
|
||||
.route("/api/attendees/:id/checkin", post(routes::checkin_attendee))
|
||||
.route("/api/attendees/batch-delete", delete(routes::batch_delete_attendees))
|
||||
.route("/api/attendees/batch-reset-checkin", post(routes::batch_reset_checkin))
|
||||
.route("/api/attendees/import", post(routes::import_attendees_from_excel))
|
||||
.route("/api/attendees/export", get(routes::export_attendees_to_excel))
|
||||
.route("/api/auth/register", post(routes::register_user))
|
||||
.route("/api/auth/login", post(routes::login_user))
|
||||
.route("/api/config", get(routes::get_configs))
|
||||
.route("/api/config/:key", get(routes::get_config))
|
||||
.route("/api/config/:key", put(routes::update_config))
|
||||
.layer(CorsLayer::new().allow_origin(Any).allow_methods(Any).allow_headers(Any))
|
||||
.with_state(db);
|
||||
|
||||
// Run the server
|
||||
let addr = env::var("SERVER_ADDR").unwrap_or("127.0.0.1:3001".to_string());
|
||||
let socket_addr: SocketAddr = addr.parse().expect("Invalid server address");
|
||||
tracing::info!("Server running on {}", socket_addr);
|
||||
axum::Server::bind(&socket_addr)
|
||||
.serve(app.into_make_service())
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
async fn root() -> &'static str {
|
||||
"签到系统 API Server"
|
||||
}
|
||||
123
src/models.rs
Normal file
123
src/models.rs
Normal file
@ -0,0 +1,123 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sea_orm::entity::prelude::*;
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
// 参会者模型
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Deserialize, Serialize)]
|
||||
#[sea_orm(table_name = "attendees")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: i32,
|
||||
pub name: String,
|
||||
pub email: Option<String>,
|
||||
pub phone: Option<String>,
|
||||
pub company: Option<String>,
|
||||
pub position: Option<String>,
|
||||
pub checked_in: bool,
|
||||
pub checkin_time: Option<DateTime<Utc>>,
|
||||
#[sea_orm(default_value = "CURRENT_TIMESTAMP")]
|
||||
pub created_at: DateTime<Utc>,
|
||||
#[sea_orm(default_value = "CURRENT_TIMESTAMP")]
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
|
||||
// 配置模型
|
||||
pub mod config {
|
||||
use super::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Deserialize, Serialize)]
|
||||
#[sea_orm(table_name = "config")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: i32,
|
||||
pub config_key: String,
|
||||
pub config_value: String,
|
||||
pub description: Option<String>,
|
||||
#[sea_orm(default_value = "CURRENT_TIMESTAMP")]
|
||||
pub created_at: DateTime<Utc>,
|
||||
#[sea_orm(default_value = "CURRENT_TIMESTAMP")]
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
}
|
||||
|
||||
// 用户模型
|
||||
pub mod users {
|
||||
use super::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Deserialize, Serialize)]
|
||||
#[sea_orm(table_name = "users")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: i32,
|
||||
pub username: String,
|
||||
pub password_hash: String,
|
||||
pub email: Option<String>,
|
||||
pub is_active: bool,
|
||||
#[sea_orm(default_value = "CURRENT_TIMESTAMP")]
|
||||
pub created_at: DateTime<Utc>,
|
||||
#[sea_orm(default_value = "CURRENT_TIMESTAMP")]
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
}
|
||||
|
||||
// 登录请求结构体
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct LoginRequest {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
// 登录响应结构体
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct LoginResponse {
|
||||
pub success: bool,
|
||||
pub message: String,
|
||||
pub token: Option<String>,
|
||||
pub user: Option<UserInfo>,
|
||||
}
|
||||
|
||||
// 用户信息结构体
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct UserInfo {
|
||||
pub id: i32,
|
||||
pub username: String,
|
||||
pub email: Option<String>,
|
||||
}
|
||||
|
||||
// 注册请求结构体
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct RegisterRequest {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
pub email: Option<String>,
|
||||
}
|
||||
|
||||
// 配置更新请求结构体
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ConfigUpdateRequest {
|
||||
pub config_key: String,
|
||||
pub config_value: String,
|
||||
}
|
||||
|
||||
// 配置响应结构体
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ConfigResponse {
|
||||
pub config_key: String,
|
||||
pub config_value: String,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
778
src/routes.rs
Normal file
778
src/routes.rs
Normal file
@ -0,0 +1,778 @@
|
||||
use axum::{
|
||||
extract::{Path, State, Query, Multipart},
|
||||
http::StatusCode,
|
||||
response::{Json, Response},
|
||||
body::Bytes,
|
||||
http::header,
|
||||
};
|
||||
use sea_orm::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use crate::models::{Entity as Attendee, ActiveModel as ActiveAttendee, Model as AttendeeModel};
|
||||
use crate::models::{users, config, LoginRequest, LoginResponse, RegisterRequest, UserInfo, ConfigUpdateRequest, ConfigResponse};
|
||||
use crate::auth::{hash_password, verify_password, generate_token};
|
||||
use calamine::{Reader, open_workbook_auto_from_rs, Sheets};
|
||||
use std::io::Cursor;
|
||||
use rust_xlsxwriter::*;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreateAttendee {
|
||||
pub name: String,
|
||||
pub email: Option<String>,
|
||||
pub phone: Option<String>,
|
||||
pub company: Option<String>,
|
||||
pub position: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct AttendeeQuery {
|
||||
pub page: Option<u64>,
|
||||
pub page_size: Option<u64>,
|
||||
pub name: Option<String>,
|
||||
pub email: Option<String>,
|
||||
pub phone: Option<String>,
|
||||
pub company: Option<String>,
|
||||
pub position: Option<String>,
|
||||
pub checked_in: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct BatchDeleteRequest {
|
||||
pub ids: Vec<i32>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct BatchResetCheckinRequest {
|
||||
pub ids: Vec<i32>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct PaginatedResponse<T> {
|
||||
pub data: Vec<T>,
|
||||
pub total: u64,
|
||||
pub page: u64,
|
||||
pub page_size: u64,
|
||||
pub total_pages: u64,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct AttendeeResponse {
|
||||
pub id: i32,
|
||||
pub name: String,
|
||||
pub email: Option<String>,
|
||||
pub phone: Option<String>,
|
||||
pub company: Option<String>,
|
||||
pub position: Option<String>,
|
||||
pub checked_in: bool,
|
||||
pub checkin_time: Option<chrono::DateTime<chrono::Utc>>,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
impl From<AttendeeModel> for AttendeeResponse {
|
||||
fn from(model: AttendeeModel) -> Self {
|
||||
Self {
|
||||
id: model.id,
|
||||
name: model.name,
|
||||
email: model.email,
|
||||
phone: model.phone,
|
||||
company: model.company,
|
||||
position: model.position,
|
||||
checked_in: model.checked_in,
|
||||
checkin_time: model.checkin_time,
|
||||
created_at: model.created_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create_attendee(
|
||||
State(db): State<DatabaseConnection>,
|
||||
Json(payload): Json<CreateAttendee>,
|
||||
) -> Result<(StatusCode, Json<AttendeeResponse>), (StatusCode, String)> {
|
||||
let active_model = ActiveAttendee {
|
||||
name: Set(payload.name),
|
||||
email: Set(payload.email),
|
||||
phone: Set(payload.phone),
|
||||
company: Set(payload.company),
|
||||
position: Set(payload.position),
|
||||
checked_in: Set(false), // 注册时默认未签到
|
||||
checkin_time: Set(None), // 注册时无签到时间
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let model = active_model
|
||||
.insert(&db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("数据库错误: {}", e)))?;
|
||||
|
||||
Ok((StatusCode::CREATED, Json(model.into())))
|
||||
}
|
||||
|
||||
pub async fn get_attendees(
|
||||
State(db): State<DatabaseConnection>,
|
||||
Query(params): Query<AttendeeQuery>,
|
||||
) -> Result<Json<PaginatedResponse<AttendeeResponse>>, (StatusCode, String)> {
|
||||
let page = params.page.unwrap_or(1);
|
||||
let page_size = params.page_size.unwrap_or(10);
|
||||
let offset = (page - 1) * page_size;
|
||||
|
||||
// 构建查询条件
|
||||
let mut query = Attendee::find();
|
||||
|
||||
if let Some(name) = ¶ms.name {
|
||||
if !name.is_empty() {
|
||||
query = query.filter(crate::models::Column::Name.contains(name));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(email) = ¶ms.email {
|
||||
if !email.is_empty() {
|
||||
query = query.filter(crate::models::Column::Email.contains(email));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(phone) = ¶ms.phone {
|
||||
if !phone.is_empty() {
|
||||
query = query.filter(crate::models::Column::Phone.contains(phone));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(company) = ¶ms.company {
|
||||
if !company.is_empty() {
|
||||
query = query.filter(crate::models::Column::Company.contains(company));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(position) = ¶ms.position {
|
||||
if !position.is_empty() {
|
||||
query = query.filter(crate::models::Column::Position.contains(position));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(checked_in) = params.checked_in {
|
||||
query = query.filter(crate::models::Column::CheckedIn.eq(checked_in));
|
||||
}
|
||||
|
||||
// 获取总数(应用查询条件)
|
||||
let total = query.clone()
|
||||
.count(&db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("数据库错误: {}", e)))?;
|
||||
|
||||
// 获取分页数据
|
||||
let models = query
|
||||
.order_by_desc(crate::models::Column::CreatedAt)
|
||||
.offset(offset)
|
||||
.limit(page_size)
|
||||
.all(&db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("数据库错误: {}", e)))?;
|
||||
|
||||
let attendees: Vec<AttendeeResponse> = models.into_iter().map(|m| m.into()).collect();
|
||||
|
||||
let total_pages = (total + page_size - 1) / page_size;
|
||||
|
||||
let response = PaginatedResponse {
|
||||
data: attendees,
|
||||
total,
|
||||
page,
|
||||
page_size,
|
||||
total_pages,
|
||||
};
|
||||
|
||||
Ok(Json(response))
|
||||
}
|
||||
|
||||
pub async fn get_attendee(
|
||||
State(db): State<DatabaseConnection>,
|
||||
Path(id): Path<i32>,
|
||||
) -> Result<Json<AttendeeResponse>, (StatusCode, String)> {
|
||||
let model = Attendee::find_by_id(id)
|
||||
.one(&db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("数据库错误: {}", e)))?
|
||||
.ok_or((StatusCode::NOT_FOUND, "参会者未找到".to_string()))?;
|
||||
|
||||
Ok(Json(model.into()))
|
||||
}
|
||||
|
||||
pub async fn checkin_attendee(
|
||||
State(db): State<DatabaseConnection>,
|
||||
Path(id): Path<i32>,
|
||||
) -> Result<Json<AttendeeResponse>, (StatusCode, String)> {
|
||||
let model = Attendee::find_by_id(id)
|
||||
.one(&db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("数据库错误: {}", e)))?
|
||||
.ok_or((StatusCode::NOT_FOUND, "参会者未找到".to_string()))?;
|
||||
|
||||
let mut active_model: ActiveAttendee = model.into();
|
||||
active_model.checked_in = Set(true);
|
||||
active_model.checkin_time = Set(Some(chrono::Utc::now()));
|
||||
|
||||
let updated_model = active_model
|
||||
.update(&db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("数据库错误: {}", e)))?;
|
||||
|
||||
Ok(Json(updated_model.into()))
|
||||
}
|
||||
|
||||
pub async fn batch_delete_attendees(
|
||||
State(db): State<DatabaseConnection>,
|
||||
Json(payload): Json<BatchDeleteRequest>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||
if payload.ids.is_empty() {
|
||||
return Err((StatusCode::BAD_REQUEST, "删除ID列表不能为空".to_string()));
|
||||
}
|
||||
|
||||
let delete_result = Attendee::delete_many()
|
||||
.filter(crate::models::Column::Id.is_in(payload.ids.clone()))
|
||||
.exec(&db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("数据库错误: {}", e)))?;
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"deleted_count": delete_result.rows_affected,
|
||||
"message": format!("成功删除 {} 条记录", delete_result.rows_affected)
|
||||
})))
|
||||
}
|
||||
|
||||
pub async fn batch_reset_checkin(
|
||||
State(db): State<DatabaseConnection>,
|
||||
Json(payload): Json<BatchResetCheckinRequest>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||
if payload.ids.is_empty() {
|
||||
return Err((StatusCode::BAD_REQUEST, "重置ID列表不能为空".to_string()));
|
||||
}
|
||||
|
||||
// 批量重置签到状态 - 使用单独的更新操作
|
||||
for id in payload.ids.iter() {
|
||||
let model = Attendee::find_by_id(*id)
|
||||
.one(&db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("数据库错误: {}", e)))?;
|
||||
|
||||
if let Some(attendee) = model {
|
||||
let mut active_model: ActiveAttendee = attendee.into();
|
||||
active_model.checked_in = Set(false);
|
||||
active_model.checkin_time = Set(None);
|
||||
|
||||
active_model
|
||||
.update(&db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("数据库错误: {}", e)))?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"updated_count": payload.ids.len(),
|
||||
"message": format!("成功重置 {} 条签到记录", payload.ids.len())
|
||||
})))
|
||||
}
|
||||
|
||||
// 用户注册
|
||||
pub async fn register_user(
|
||||
State(db): State<DatabaseConnection>,
|
||||
Json(payload): Json<RegisterRequest>,
|
||||
) -> Result<Json<LoginResponse>, (StatusCode, String)> {
|
||||
// 检查用户名是否已存在
|
||||
let existing_user = users::Entity::find()
|
||||
.filter(users::Column::Username.eq(&payload.username))
|
||||
.one(&db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("数据库错误: {}", e)))?;
|
||||
|
||||
if existing_user.is_some() {
|
||||
return Ok(Json(LoginResponse {
|
||||
success: false,
|
||||
message: "用户名已存在".to_string(),
|
||||
token: None,
|
||||
user: None,
|
||||
}));
|
||||
}
|
||||
|
||||
// 加密密码
|
||||
let password_hash = hash_password(&payload.password)
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("密码加密失败: {}", e)))?;
|
||||
|
||||
// 创建新用户
|
||||
let new_user = users::ActiveModel {
|
||||
username: Set(payload.username.clone()),
|
||||
password_hash: Set(password_hash),
|
||||
email: Set(payload.email.clone()),
|
||||
is_active: Set(true),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let user = new_user
|
||||
.insert(&db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("创建用户失败: {}", e)))?;
|
||||
|
||||
// 生成JWT令牌
|
||||
let token = generate_token(user.id, &user.username)
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("生成令牌失败: {}", e)))?;
|
||||
|
||||
Ok(Json(LoginResponse {
|
||||
success: true,
|
||||
message: "注册成功".to_string(),
|
||||
token: Some(token),
|
||||
user: Some(UserInfo {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
}),
|
||||
}))
|
||||
}
|
||||
|
||||
// 用户登录
|
||||
pub async fn login_user(
|
||||
State(db): State<DatabaseConnection>,
|
||||
Json(payload): Json<LoginRequest>,
|
||||
) -> Result<Json<LoginResponse>, (StatusCode, String)> {
|
||||
// 查找用户
|
||||
let user = users::Entity::find()
|
||||
.filter(users::Column::Username.eq(&payload.username))
|
||||
.one(&db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("数据库错误: {}", e)))?;
|
||||
|
||||
let user = match user {
|
||||
Some(user) => user,
|
||||
None => {
|
||||
return Ok(Json(LoginResponse {
|
||||
success: false,
|
||||
message: "用户名或密码错误".to_string(),
|
||||
token: None,
|
||||
user: None,
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
// 验证密码
|
||||
let is_valid = verify_password(&payload.password, &user.password_hash)
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("密码验证失败: {}", e)))?;
|
||||
|
||||
if !is_valid {
|
||||
return Ok(Json(LoginResponse {
|
||||
success: false,
|
||||
message: "用户名或密码错误".to_string(),
|
||||
token: None,
|
||||
user: None,
|
||||
}));
|
||||
}
|
||||
|
||||
// 检查用户是否激活
|
||||
if !user.is_active {
|
||||
return Ok(Json(LoginResponse {
|
||||
success: false,
|
||||
message: "用户账户已被禁用".to_string(),
|
||||
token: None,
|
||||
user: None,
|
||||
}));
|
||||
}
|
||||
|
||||
// 生成JWT令牌
|
||||
let token = generate_token(user.id, &user.username)
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("生成令牌失败: {}", e)))?;
|
||||
|
||||
Ok(Json(LoginResponse {
|
||||
success: true,
|
||||
message: "登录成功".to_string(),
|
||||
token: Some(token),
|
||||
user: Some(UserInfo {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
}),
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ImportResult {
|
||||
pub success: bool,
|
||||
pub message: String,
|
||||
pub imported_count: usize,
|
||||
pub failed_count: usize,
|
||||
pub errors: Vec<String>,
|
||||
}
|
||||
|
||||
pub async fn import_attendees_from_excel(
|
||||
State(db): State<DatabaseConnection>,
|
||||
mut multipart: Multipart,
|
||||
) -> Result<Json<ImportResult>, (StatusCode, String)> {
|
||||
let mut imported_count = 0;
|
||||
let mut failed_count = 0;
|
||||
let mut errors = Vec::new();
|
||||
|
||||
// 获取上传的文件
|
||||
while let Some(field) = multipart.next_field().await.map_err(|e| {
|
||||
(StatusCode::BAD_REQUEST, format!("解析文件失败: {}", e))
|
||||
})? {
|
||||
if field.name() == Some("file") {
|
||||
let data = field.bytes().await.map_err(|e| {
|
||||
(StatusCode::BAD_REQUEST, format!("读取文件数据失败: {}", e))
|
||||
})?;
|
||||
|
||||
// 解析Excel文件
|
||||
let cursor = Cursor::new(data.to_vec());
|
||||
let mut workbook: Sheets<_> = open_workbook_auto_from_rs(cursor).map_err(|e| {
|
||||
(StatusCode::BAD_REQUEST, format!("打开Excel文件失败: {}", e))
|
||||
})?;
|
||||
|
||||
// 获取第一个工作表
|
||||
let worksheet_names = workbook.sheet_names();
|
||||
if worksheet_names.is_empty() {
|
||||
return Err((StatusCode::BAD_REQUEST, "Excel文件中没有工作表".to_string()));
|
||||
}
|
||||
|
||||
let range = match workbook.worksheet_range(&worksheet_names[0]) {
|
||||
Some(Ok(range)) => range,
|
||||
Some(Err(e)) => return Err((StatusCode::BAD_REQUEST, format!("读取工作表失败: {}", e))),
|
||||
None => return Err((StatusCode::BAD_REQUEST, "工作表不存在".to_string())),
|
||||
};
|
||||
|
||||
// 解析数据行(跳过标题行)
|
||||
let mut row_index = 0;
|
||||
for row in range.rows() {
|
||||
row_index += 1;
|
||||
|
||||
// 跳过标题行
|
||||
if row_index == 1 {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 检查行是否为空
|
||||
if row.is_empty() || row.iter().all(|cell| cell.is_empty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 解析参会者数据
|
||||
// 假设Excel列顺序为: 姓名, 邮箱, 手机号, 公司, 职位
|
||||
let name = row.get(0).map(|v| v.to_string()).unwrap_or_default().trim().to_string();
|
||||
let email = row.get(1).map(|v| v.to_string()).unwrap_or_default().trim().to_string();
|
||||
let email = if email.is_empty() { None } else { Some(email) };
|
||||
let phone = row.get(2).map(|v| v.to_string()).filter(|s| !s.trim().is_empty());
|
||||
let company = row.get(3).map(|v| v.to_string()).filter(|s| !s.trim().is_empty());
|
||||
let position = row.get(4).map(|v| v.to_string()).filter(|s| !s.trim().is_empty());
|
||||
|
||||
// 验证必填字段
|
||||
if name.is_empty() {
|
||||
errors.push(format!("第{}行: 姓名不能为空", row_index));
|
||||
failed_count += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 检查姓名和手机号组合是否已存在
|
||||
let mut query = Attendee::find().filter(crate::models::Column::Name.eq(&name));
|
||||
|
||||
// 如果有手机号,则同时检查姓名和手机号的组合
|
||||
if let Some(ref phone_value) = phone {
|
||||
query = query.filter(crate::models::Column::Phone.eq(phone_value));
|
||||
|
||||
let existing = query
|
||||
.one(&db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("数据库查询失败: {}", e)))?;
|
||||
|
||||
if existing.is_some() {
|
||||
errors.push(format!("第{}行: 姓名 {} 和手机号 {} 的组合已存在", row_index, name, phone_value));
|
||||
failed_count += 1;
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
// 如果没有手机号,只检查姓名
|
||||
let existing = query
|
||||
.filter(crate::models::Column::Phone.is_null())
|
||||
.one(&db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("数据库查询失败: {}", e)))?;
|
||||
|
||||
if existing.is_some() {
|
||||
errors.push(format!("第{}行: 姓名 {} (无手机号)已存在", row_index, name));
|
||||
failed_count += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// 创建参会者记录
|
||||
let active_model = ActiveAttendee {
|
||||
name: Set(name),
|
||||
email: Set(email),
|
||||
phone: Set(phone),
|
||||
company: Set(company),
|
||||
position: Set(position),
|
||||
checked_in: Set(false),
|
||||
checkin_time: Set(None),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
match active_model.insert(&db).await {
|
||||
Ok(_) => imported_count += 1,
|
||||
Err(e) => {
|
||||
errors.push(format!("第{}行: 保存失败 - {}", row_index, e));
|
||||
failed_count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
break; // 只处理第一个文件
|
||||
}
|
||||
}
|
||||
|
||||
let message = if failed_count == 0 {
|
||||
format!("成功导入 {} 条记录", imported_count)
|
||||
} else {
|
||||
format!("导入完成:成功 {} 条,失败 {} 条", imported_count, failed_count)
|
||||
};
|
||||
|
||||
Ok(Json(ImportResult {
|
||||
success: failed_count == 0,
|
||||
message,
|
||||
imported_count,
|
||||
failed_count,
|
||||
errors,
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn export_attendees_to_excel(
|
||||
State(db): State<DatabaseConnection>,
|
||||
Query(params): Query<AttendeeQuery>,
|
||||
) -> Result<(StatusCode, [(String, String); 2], Bytes), (StatusCode, String)> {
|
||||
// 构建查询条件
|
||||
let mut query = Attendee::find();
|
||||
|
||||
if let Some(name) = ¶ms.name {
|
||||
query = query.filter(crate::models::Column::Name.contains(name));
|
||||
}
|
||||
if let Some(email) = ¶ms.email {
|
||||
query = query.filter(crate::models::Column::Email.contains(email));
|
||||
}
|
||||
if let Some(phone) = ¶ms.phone {
|
||||
query = query.filter(crate::models::Column::Phone.contains(phone));
|
||||
}
|
||||
if let Some(company) = ¶ms.company {
|
||||
query = query.filter(crate::models::Column::Company.contains(company));
|
||||
}
|
||||
if let Some(position) = ¶ms.position {
|
||||
query = query.filter(crate::models::Column::Position.contains(position));
|
||||
}
|
||||
if let Some(checked_in) = params.checked_in {
|
||||
query = query.filter(crate::models::Column::CheckedIn.eq(checked_in));
|
||||
}
|
||||
|
||||
// 获取所有符合条件的参会者
|
||||
let attendees = query.all(&db).await.map_err(|e| {
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, format!("数据库查询失败: {}", e))
|
||||
})?;
|
||||
|
||||
// 创建 Excel 文件
|
||||
let mut workbook = Workbook::new();
|
||||
let worksheet = workbook.add_worksheet();
|
||||
|
||||
// 写入表头 - 按照前端界面字段顺序
|
||||
let headers = ["ID", "姓名", "手机号", "公司", "职位", "签到状态", "签到时间", "邮箱", "注册时间"];
|
||||
for (col, header) in headers.iter().enumerate() {
|
||||
worksheet.write_string(0, col as u16, *header).map_err(|e| {
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, format!("写入表头失败: {}", e))
|
||||
})?;
|
||||
}
|
||||
|
||||
// 写入数据 - 按照前端界面字段顺序
|
||||
for (row, attendee) in attendees.iter().enumerate() {
|
||||
let row_idx = (row + 1) as u32;
|
||||
|
||||
// ID
|
||||
worksheet.write_number(row_idx, 0, attendee.id as f64).map_err(|e| {
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, format!("写入数据失败: {}", e))
|
||||
})?;
|
||||
|
||||
// 姓名
|
||||
worksheet.write_string(row_idx, 1, &attendee.name).map_err(|e| {
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, format!("写入数据失败: {}", e))
|
||||
})?;
|
||||
|
||||
// 手机号
|
||||
worksheet.write_string(row_idx, 2, attendee.phone.as_deref().unwrap_or("")).map_err(|e| {
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, format!("写入数据失败: {}", e))
|
||||
})?;
|
||||
|
||||
// 公司
|
||||
worksheet.write_string(row_idx, 3, attendee.company.as_deref().unwrap_or("")).map_err(|e| {
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, format!("写入数据失败: {}", e))
|
||||
})?;
|
||||
|
||||
// 职位
|
||||
worksheet.write_string(row_idx, 4, attendee.position.as_deref().unwrap_or("")).map_err(|e| {
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, format!("写入数据失败: {}", e))
|
||||
})?;
|
||||
|
||||
// 签到状态
|
||||
worksheet.write_string(row_idx, 5, if attendee.checked_in { "已签到" } else { "未签到" }).map_err(|e| {
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, format!("写入数据失败: {}", e))
|
||||
})?;
|
||||
|
||||
// 签到时间 - 转换为北京时间 (UTC+8)
|
||||
let checkin_time_str = attendee.checkin_time
|
||||
.map(|t| {
|
||||
// 将UTC时间转换为北京时间 (UTC+8)
|
||||
let beijing_time = t + chrono::Duration::hours(8);
|
||||
beijing_time.format("%Y-%m-%d %H:%M:%S").to_string()
|
||||
})
|
||||
.unwrap_or_else(|| "-".to_string());
|
||||
worksheet.write_string(row_idx, 6, &checkin_time_str).map_err(|e| {
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, format!("写入数据失败: {}", e))
|
||||
})?;
|
||||
|
||||
// 邮箱
|
||||
let email_str = attendee.email.as_deref().unwrap_or("-");
|
||||
worksheet.write_string(row_idx, 7, email_str).map_err(|e| {
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, format!("写入数据失败: {}", e))
|
||||
})?;
|
||||
|
||||
// 注册时间 - 转换为北京时间 (UTC+8)
|
||||
let created_at_str = {
|
||||
let beijing_time = attendee.created_at + chrono::Duration::hours(8);
|
||||
beijing_time.format("%Y-%m-%d %H:%M:%S").to_string()
|
||||
};
|
||||
worksheet.write_string(row_idx, 8, &created_at_str).map_err(|e| {
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, format!("写入数据失败: {}", e))
|
||||
})?;
|
||||
}
|
||||
|
||||
// 获取 Excel 文件数据
|
||||
let excel_data = workbook.save_to_buffer().map_err(|e| {
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, format!("生成Excel文件失败: {}", e))
|
||||
})?;
|
||||
|
||||
// 生成文件名
|
||||
let filename = format!("attendees_{}.xlsx", chrono::Utc::now().format("%Y%m%d_%H%M%S"));
|
||||
|
||||
// 设置响应头
|
||||
let headers = [
|
||||
(header::CONTENT_TYPE.as_str().to_string(), "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet".to_string()),
|
||||
(header::CONTENT_DISPOSITION.as_str().to_string(), format!("attachment; filename=\"{}\"", filename)),
|
||||
];
|
||||
|
||||
Ok((StatusCode::OK, headers, Bytes::from(excel_data)))
|
||||
}
|
||||
|
||||
// 获取所有配置
|
||||
pub async fn get_configs(
|
||||
State(db): State<DatabaseConnection>,
|
||||
) -> Response<String> {
|
||||
let configs = match config::Entity::find()
|
||||
.all(&db)
|
||||
.await {
|
||||
Ok(configs) => configs,
|
||||
Err(e) => {
|
||||
return Response::builder()
|
||||
.status(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
.header(header::CONTENT_TYPE, "application/json; charset=utf-8")
|
||||
.body(format!(r#"{{"error": "数据库错误: {}"}}"#, e))
|
||||
.unwrap();
|
||||
}
|
||||
};
|
||||
|
||||
let config_responses: Vec<ConfigResponse> = configs
|
||||
.into_iter()
|
||||
.map(|config| ConfigResponse {
|
||||
config_key: config.config_key,
|
||||
config_value: config.config_value,
|
||||
description: config.description,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let json_str = match serde_json::to_string(&config_responses) {
|
||||
Ok(json) => json,
|
||||
Err(e) => {
|
||||
return Response::builder()
|
||||
.status(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
.header(header::CONTENT_TYPE, "application/json; charset=utf-8")
|
||||
.body(format!(r#"{{"error": "序列化错误: {}"}}"#, e))
|
||||
.unwrap();
|
||||
}
|
||||
};
|
||||
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, "application/json; charset=utf-8")
|
||||
.body(json_str)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
// 获取单个配置
|
||||
pub async fn get_config(
|
||||
State(db): State<DatabaseConnection>,
|
||||
Path(key): Path<String>,
|
||||
) -> Result<Json<ConfigResponse>, (StatusCode, String)> {
|
||||
let config = config::Entity::find()
|
||||
.filter(config::Column::ConfigKey.eq(&key))
|
||||
.one(&db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("数据库错误: {}", e)))?;
|
||||
|
||||
match config {
|
||||
Some(config) => Ok(Json(ConfigResponse {
|
||||
config_key: config.config_key,
|
||||
config_value: config.config_value,
|
||||
description: config.description,
|
||||
})),
|
||||
None => Err((StatusCode::NOT_FOUND, "配置项不存在".to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
// 更新配置
|
||||
pub async fn update_config(
|
||||
State(db): State<DatabaseConnection>,
|
||||
Path(key): Path<String>,
|
||||
Json(payload): Json<ConfigUpdateRequest>,
|
||||
) -> Result<Json<ConfigResponse>, (StatusCode, String)> {
|
||||
// 查找现有配置
|
||||
let existing_config = config::Entity::find()
|
||||
.filter(config::Column::ConfigKey.eq(&key))
|
||||
.one(&db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("数据库错误: {}", e)))?;
|
||||
|
||||
match existing_config {
|
||||
Some(config) => {
|
||||
// 更新现有配置
|
||||
let mut active_config: config::ActiveModel = config.into();
|
||||
active_config.config_value = Set(payload.config_value.clone());
|
||||
active_config.updated_at = Set(chrono::Utc::now());
|
||||
|
||||
let updated_config = active_config
|
||||
.update(&db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("数据库错误: {}", e)))?;
|
||||
|
||||
Ok(Json(ConfigResponse {
|
||||
config_key: updated_config.config_key,
|
||||
config_value: updated_config.config_value,
|
||||
description: updated_config.description,
|
||||
}))
|
||||
}
|
||||
None => {
|
||||
// 创建新配置
|
||||
let new_config = config::ActiveModel {
|
||||
config_key: Set(payload.config_key),
|
||||
config_value: Set(payload.config_value),
|
||||
description: Set(None),
|
||||
created_at: Set(chrono::Utc::now()),
|
||||
updated_at: Set(chrono::Utc::now()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let created_config = new_config
|
||||
.insert(&db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("数据库错误: {}", e)))?;
|
||||
|
||||
Ok(Json(ConfigResponse {
|
||||
config_key: created_config.config_key,
|
||||
config_value: created_config.config_value,
|
||||
description: created_config.description,
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user