趣味课堂智能点名系统:她做老师,我做工具人

🚀 项目背景  
女友是一名老师,每天点名时都要手动喊学生名字,不仅低效还容易漏人。于是我动手造了一个“趣味课堂智能点名系统”:  
- 支持导入 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

🎓 如果你是老师,欢迎使用这个工具提高课堂趣味  
🧑‍💻 如果你是程序员,不妨也帮你的老师朋友做一个  
🌟 欢迎点赞 + 收藏 + 关注支持~

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值