This commit is contained in:
2025-10-23 00:22:43 +08:00
parent 0881d3fbcb
commit d67ab615ce
34 changed files with 9442 additions and 1 deletions

BIN
frontend/.DS_Store vendored Normal file

Binary file not shown.

13
frontend/index.html Normal file
View 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

File diff suppressed because it is too large Load Diff

24
frontend/package.json Normal file
View 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"
}
}

View 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
View 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
View 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;

View 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;

View 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;

View 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;

View 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
View 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
View 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
View 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);
});
}
}
}
}
})