🚀 项目背景
女友是一名老师,每天点名时都要手动喊学生名字,不仅低效还容易漏人。于是我动手造了一个“趣味课堂智能点名系统”:
- 支持导入 Excel 学生名单
- 一键随机抽取学生
- 抽中人居中高亮显示
- 搭配 🎵 音效 + 🎉 烟花动效
- 仪式感拉满!
👩 她在课堂上用了一次,学生们都玩疯了,感觉自己像被幸运女神翻牌了一样哈哈哈 😆
👇 本文将带你完整实现这个系统:
1、整体代码框架
fun-classroom
┣components // 通用功能组件
┃ ┣ ImportButton.jsx // 导入学生 Excel 按钮
┃ ┣ RandomPicker.jsx // “抽取学生”按钮
┃ ┗ StudentList.jsx // 显示学生列表组件
┣styles // 样式文件(CSS Modules)
┃ ┣ App.module.css // 页面整体布局样式
┃ ┣ Button.module.css // 通用按钮样式
┃ ┗ StudentList.module.css// 学生列表 + 高亮框样式
┣App.jsx // 应用主入口,核心逻辑 + 状态控制
┣index.js // React 挂载根节点
┣sounds/ // 音效文件(tick.mp3/ding.mp3/victory.mp3)
┣public/
┃ ┗index.html // 页面 HTML 模板
┣package.json // 项目信息 & 依赖配置
2、App.jsx
📌 作用:项目核心,负责状态管理、滚动动画、音效控制、弹窗显示
import React, { useState, useRef, useEffect } from 'react';
import * as XLSX from 'xlsx';
import styles from './styles/App.module.css';
import ImportButton from './components/ImportButton';
import StudentList from './components/StudentList';
import RandomPicker from './components/RandomPicker';
import Confetti from 'react-confetti';
import { useWindowSize } from '@react-hook/window-size';
function App() {
const [students, setStudents] = useState([]);
const [selectedIndex, setSelectedIndex] = useState(null);
const [finalStudent, setFinalStudent] = useState(null);
const [showCelebration, setShowCelebration] = useState(false);
const listRef = useRef(null);
const tickSound = useRef(null);
const dingSound = useRef(null);
const victorySound = useRef(null);
const [width, height] = useWindowSize();
useEffect(() => {
tickSound.current = new Audio('/sounds/tick.mp3');
dingSound.current = new Audio('/sounds/ding.mp3');
victorySound.current = new Audio('/sounds/victory.mp3');
}, []);
const safePlay = (audioRef) => {
const audio = audioRef.current;
if (audio && audio.paused) {
audio.currentTime = 0;
audio.play().catch(() => {});
}
};
const handleImport = (e) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (evt) => {
const data = new Uint8Array(evt.target.result);
const workbook = XLSX.read(data, { type: 'array' });
const worksheet = workbook.Sheets[workbook.SheetNames[0]];
const jsonData = XLSX.utils.sheet_to_json(worksheet, { header: 1 });
const names = jsonData.flat().filter(name => name);
setStudents(names.map(name => ({ name })));
};
reader.readAsArrayBuffer(file);
};
const pickRandomStudent = () => {
if (students.length === 0) return;
setSelectedIndex(null);
setFinalStudent(null);
setShowCelebration(false);
const itemHeight = 56;
const visibleCount = 5;
const centerOffset = Math.floor(visibleCount / 2) * itemHeight;
const targetIndex = Math.floor(Math.random() * students.length);
const repeat = 6;
const totalIndex = students.length * repeat + targetIndex;
const targetScrollTop = totalIndex * itemHeight - centerOffset;
const scrollElement = listRef.current;
const startScrollTop = scrollElement.scrollTop;
const distance = targetScrollTop - startScrollTop;
const duration = 1500;
const startTime = performance.now();
const linearEase = (x) => x;
const animate = (now) => {
const elapsed = now - startTime;
const progress = Math.min(elapsed / duration, 1);
const eased = linearEase(progress);
scrollElement.scrollTop = startScrollTop + distance * eased;
if (progress < 1) {
requestAnimationFrame(animate);
safePlay(tickSound);
} else {
scrollElement.scrollTop = targetScrollTop;
safePlay(dingSound);
safePlay(victorySound);
setSelectedIndex(targetIndex);
setFinalStudent(students[targetIndex]);
setShowCelebration(true);
setTimeout(() => setShowCelebration(false), 4000);
}
};
requestAnimationFrame(animate);
};
return (
<div className={styles.container} onClick={() => setSelectedIndex(null)}>
<h1 className={styles.title}>趣味课堂智能点名系统</h1>
<div className={styles.mainContent}>
<ImportButton onChange={handleImport} />
<StudentList
students={students}
selectedIndex={selectedIndex}
listRef={listRef}
/>
<RandomPicker onClick={pickRandomStudent} />
</div>
{showCelebration && finalStudent && (
<>
<Confetti width={width} height={height} numberOfPieces={200} recycle={false} />
<div className={styles.modalOverlay}>
<div className={styles.modalBox}>
🎉 恭喜 <strong>{finalStudent.name}</strong> 被抽中!
<br />点击空白处关闭
</div>
</div>
</>
)}
</div>
);
}
export default App;
3、components/ImportButton.jsx
作用:上传 Excel 文件并触发回调
import React from 'react';
import styles from '../styles/Button.module.css';
const ImportButton = ({ onChange }) => {
return (
<label className={styles.button}>
📥 导入学生名单
<input
type="file"
accept=".xlsx, .xls"
style={{ display: 'none' }}
onChange={onChange}
/>
</label>
);
};
export default ImportButton;
4、components/RandomPicker.jsx
📌 作用:底部“抽取学生”按钮,触发滚动逻辑
import React from 'react';
import styles from '../styles/Button.module.css';
const RandomPicker = ({ onClick }) => {
return (
<button className={styles.button} onClick={onClick}>
🎯 抽取学生
</button>
);
};
export default RandomPicker;
5、components/StudentList.jsx
📌 作用:展示重复滚动学生列表 + 蓝色高亮框
import React from 'react';
import styles from '../styles/StudentList.module.css';
const StudentList = ({ students, selectedIndex, listRef }) => {
const repeatedList = Array(6).fill(students).flat();
return (
<div className={styles.wrapper}>
<div className={styles.highlightBox}></div>
<div className={styles.listContainer} ref={listRef}>
{repeatedList.map((student, i) => (
<div
key={i}
className={`${styles.studentItem} ${
i % students.length === selectedIndex ? styles.selected : ''
}`}
>
<div className={styles.avatar}>👧</div>
<span>{student.name}</span>
</div>
))}
</div>
</div>
);
};
export default StudentList;
6、styles/Button.module.css
📌 作用:统一按钮样式(导入、抽取)
.button {
background-color: #facc15;
color: #1f2937;
padding: 0.6rem 1.2rem;
font-weight: bold;
border: none;
border-radius: 10px;
cursor: pointer;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
font-size: 1rem;
margin: 10px 0;
transition: all 0.3s ease;
}
.button:hover {
background-color: #fbbf24;
transform: scale(1.05);
}
7、styles/StudentList.module.css
📌 作用:学生列表样式 + 中间高亮框
.wrapper {
position: relative;
height: 280px;
width: 400px;
overflow: hidden;
border-radius: 12px;
background: white;
margin-bottom: 1rem;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
.listContainer {
height: 100%;
overflow-y: scroll;
scroll-behavior: smooth;
}
.studentItem {
height: 56px;
display: flex;
align-items: center;
background: #f1f5f9;
padding: 0.6rem 1rem;
font-size: 1rem;
border-bottom: 1px solid #e2e8f0;
transition: background-color 0.3s ease, transform 0.3s ease;
}
.avatar {
background: #fca5a5;
border-radius: 50%;
width: 36px;
height: 36px;
display: flex;
justify-content: center;
align-items: center;
margin-right: 0.8rem;
}
.highlightBox {
position: absolute;
top: calc(50% - 28px);
left: 10px;
right: 10px;
height: 56px;
border: 2px dashed #60a5fa;
background-color: rgba(96, 165, 250, 0.05);
border-radius: 10px;
pointer-events: none;
z-index: 10;
}
.selected {
background-color: #dbeafe !important;
transform: scale(1.05);
font-weight: bold;
}
8、styles/App.module.css
📌 作用:页面整体布局 + 弹窗
.container {
text-align: center;
background-color: #e5f0fb;
min-height: 100vh;
padding-top: 2rem;
}
.title {
font-size: 2rem;
font-weight: bold;
color: #1e3a8a;
margin-bottom: 1.5rem;
}
.mainContent {
display: flex;
flex-direction: column;
align-items: center;
}
.modalOverlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(30, 41, 59, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.modalBox {
background: white;
padding: 2rem;
border-radius: 1rem;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
font-size: 1.5rem;
font-weight: bold;
color: #1e3a8a;
}
9、index.js
📌 作用:React 项目入口挂载 App
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
10、public/sounds/
目录中放置:
-
tick.mp3
(滚动每帧声音) -
ding.mp3
(抽中提示音) victory.mp3
(烟花庆祝音)- 夸克资源链接:我用夸克网盘分享了「fun-classroom」,点击链接即可保存。打开「夸克APP」,无需下载在线播放视频,畅享原画5倍速,支持电视投屏。
链接:https://pan.quark.cn/s/d4caef223e46
🎓 如果你是老师,欢迎使用这个工具提高课堂趣味
🧑💻 如果你是程序员,不妨也帮你的老师朋友做一个
🌟 欢迎点赞 + 收藏 + 关注支持~